Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

XLOOKUP function #1469

Merged
merged 48 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
932e8c0
ISS-1 sapiologie/hyperformula XLOOKUP in built-in-functions.md
selimyoussry Jun 15, 2024
f0f9545
ISS-1 sapiologie/hyperformula add translations
selimyoussry Jun 15, 2024
384bcb8
ISS-1 sapiologie/hyperformula placeholder function and test
selimyoussry Jun 15, 2024
eecce3a
ISS-1 sapiologie/hyperformula progress on Xlookup logic
selimyoussry Jun 16, 2024
e9cabd6
ISS-1 sapiologie/hyperformula Working XLOOKUP without support for opt…
selimyoussry Jun 16, 2024
83be86c
ISS-1 sapiologie/hyperformula override default if_not_found
selimyoussry Jun 16, 2024
5101a90
ISS-1 sapiologie/hyperformula add commented out range function return…
selimyoussry Jun 16, 2024
08445d1
ISS-1 sapiologie/hyperformula update the doc to reflect limitations
selimyoussry Jun 16, 2024
9ece67c
ISS-1 sapiologie/hyperformula Set vectorizationForbidden: true on XLO…
selimyoussry Jun 17, 2024
9c448c2
Add test suite for the XLOOKUP function
sequba Dec 5, 2024
167cac8
Plan basic tests for XLOOKUP
sequba Dec 5, 2024
1f9d091
Merge branch 'iss-1-xlookup' of github.com:sapiologie/hyperformula in…
sequba Dec 5, 2024
0811866
Merge branch 'sapiologie-iss-1-xlookup' into feature/issue-1458
sequba Dec 5, 2024
8699958
Make XLOOKUP work in basic mode
sequba Dec 5, 2024
13b9cc0
Make XLOOKUP return a range
sequba Dec 5, 2024
d3c6a26
Add unit tests about the types of argument
sequba Dec 5, 2024
af99365
Fix XLOOKUP for scenario with 2D returnArray
sequba Dec 5, 2024
052e30c
Add unit tests for argument validation
sequba Dec 7, 2024
e1cd83b
Add official Exel example 4
sequba Dec 7, 2024
3135c28
Implement XLOOKUP for single-cell ranges
sequba Dec 8, 2024
4a15b95
Refactor xlookupArraySize function
sequba Dec 9, 2024
7cd0a99
Fix the single-cell ranges tests
sequba Dec 9, 2024
bfac296
Add unit tests for non-default searchMode
sequba Dec 9, 2024
d045b81
Make XLOOKUP handle sorted ranges
sequba Dec 17, 2024
19a4d8e
Return NotFound if there is no match in sprted ranges
sequba Dec 18, 2024
f25b623
Finish implementing searchMode for XLOOKUP
sequba Dec 18, 2024
5c4b5a2
Add unit tests for ColumnIndex column search strategy
sequba Dec 18, 2024
5829994
Add unt tests for matchMode
sequba Dec 20, 2024
fd1001f
Make searchStrategy.find() able to return lower/upper bound if there …
sequba Dec 22, 2024
2c4967b
Add unit tests for matchMode -1
sequba Dec 22, 2024
47adb9b
Improve AdvancedFind.basicFind() to handle lower and upper bounds whe…
sequba Dec 22, 2024
71cec98
Implement matchMode -1 and 1 for XLOOKUP
sequba Dec 22, 2024
c8f7c11
Fix the issue with MATCH function
sequba Dec 22, 2024
b6a8449
Fixx ColumnIndex.find() issue
sequba Dec 22, 2024
8f7bdd4
Implement wildcard match for XLOOKUP
sequba Dec 23, 2024
56b4d83
Add more unit tests
sequba Jan 2, 2025
4ccbca4
Add unit tests for wildcard match with ColumnIndex column search stra…
sequba Jan 2, 2025
1fedb19
Improve descriptions of some unit tests
sequba Jan 2, 2025
a6d3049
Fix lint error
sequba Jan 2, 2025
814049b
Add changelog entry
sequba Jan 2, 2025
db6e4b3
Describe XOOKUP in the built-in functions guide
sequba Jan 2, 2025
c420918
Rephrase XLOOKUP description
sequba Jan 2, 2025
c02f877
Merge branch 'develop' into feature/issue-1458
sequba Jan 2, 2025
e70a56b
Remove redundant\ comments
sequba Jan 2, 2025
a398fb1
Refactor findLastOccurrenceInOrderedRange function
sequba Jan 2, 2025
24f7d04
Fix misspelled word occurrence
sequba Jan 2, 2025
e6d8013
Remove redundant comment
sequba Jan 2, 2025
52f540f
Remove redundant guards
sequba Jan 3, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Added

- Added a new function: XLOOKUP. [#1458](https://github.com/handsontable/hyperformula/issues/1458)

### Changed

- **Breaking change**: Changed ES module build to use `mjs` files and `exports` property in `package.json` to make importing language files possible in Node environment. [#1344](https://github.com/handsontable/hyperformula/issues/1344)
Expand Down
31 changes: 16 additions & 15 deletions docs/guide/built-in-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,21 +212,22 @@ Total number of functions: **{{ $page.functionsCount }}**

### Lookup and reference

| Function ID | Description | Syntax |
|:------------|:-----------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------------------------------------|
| ADDRESS | Returns a cell reference as a string. | ADDRESS(Row, Column[, AbsoluteRelativeMode[, UseA1Notation[, Sheet]]]) |
| CHOOSE | Uses an index to return a value from a list of values. | CHOOSE(Index, Value1, Value2, ...ValueN) |
| COLUMN | Returns column number of a given reference or formula reference if argument not provided. | COLUMNS([Reference]) |
| COLUMNS | Returns the number of columns in the given reference. | COLUMNS(Array) |
| FORMULATEXT | Returns a formula in a given cell as a string. | FORMULATEXT(Reference) |
| HLOOKUP | Searches horizontally with reference to adjacent cells to the bottom. | HLOOKUP(Search_Criterion, Array, Index, Sort_Order) |
| HYPERLINK | Stores the url in the cell's metadata. It can be read using method [`getCellHyperlink`](../api/classes/hyperformula.md#getcellhyperlink) | HYPERLINK(Url[, LinkLabel]) |
| INDEX | Returns the contents of a cell specified by row and column number. The column number is optional and defaults to 1. | INDEX(Range, Row [, Column]) |
| MATCH | Returns the relative position of an item in an array that matches a specified value. | MATCH(Searchcriterion, Lookuparray [, MatchType]) |
| OFFSET | Returns the value of a cell offset by a certain number of rows and columns from a given reference point. | OFFSET(Reference, Rows, Columns, Height, Width) |
| ROW | Returns row number of a given reference or formula reference if argument not provided. | ROW([Reference]) |
| ROWS | Returns the number of rows in the given reference. | ROWS(Array) |
| VLOOKUP | Searches vertically with reference to adjacent cells to the right. | VLOOKUP(Search_Criterion, Array, Index, Sort_Order) |
| Function ID | Description | Syntax |
|:------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------------------------------------------|
| ADDRESS | Returns a cell reference as a string. | ADDRESS(Row, Column[, AbsoluteRelativeMode[, UseA1Notation[, Sheet]]]) |
| CHOOSE | Uses an index to return a value from a list of values. | CHOOSE(Index, Value1, Value2, ...ValueN) |
| COLUMN | Returns column number of a given reference or formula reference if argument not provided. | COLUMNS([Reference]) |
| COLUMNS | Returns the number of columns in the given reference. | COLUMNS(Array) |
| FORMULATEXT | Returns a formula in a given cell as a string. | FORMULATEXT(Reference) |
| HLOOKUP | Searches horizontally with reference to adjacent cells to the bottom. | HLOOKUP(Search_Criterion, Array, Index, Sort_Order) |
| HYPERLINK | Stores the url in the cell's metadata. It can be read using method [`getCellHyperlink`](../api/classes/hyperformula.md#getcellhyperlink) | HYPERLINK(Url[, LinkLabel]) |
| INDEX | Returns the contents of a cell specified by row and column number. The column number is optional and defaults to 1. | INDEX(Range, Row [, Column]) |
| MATCH | Returns the relative position of an item in an array that matches a specified value. | MATCH(Searchcriterion, LookupArray [, MatchType]) |
| OFFSET | Returns the value of a cell offset by a certain number of rows and columns from a given reference point. | OFFSET(Reference, Rows, Columns, Height, Width) |
| ROW | Returns row number of a given reference or formula reference if argument not provided. | ROW([Reference]) |
| ROWS | Returns the number of rows in the given reference. | ROWS(Array) |
| VLOOKUP | Searches vertically with reference to adjacent cells to the right. | VLOOKUP(Search_Criterion, Array, Index, Sort_Order) |
| XLOOKUP | Searches for a key in a range and returns the item corresponding to the match it finds. If no match exists, then XLOOKUP can return the closest (approximate) match. | XLOOKUP(LookupValue, LookupArray, ReturnArray, [IfNotFound], [MatchMode], [SearchMode]) |

### Math and trigonometry

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"test": "npm-run-all lint test:unit test:browser",
"test:unit": "cross-env NODE_ICU_DATA=node_modules/full-icu jest",
"test:watch": "cross-env NODE_ICU_DATA=node_modules/full-icu jest --watch",
"test:watch-tmp": "cross-env NODE_ICU_DATA=node_modules/full-icu jest --watch xlookup",
"test:coverage": "npm run test:unit -- --coverage",
"test:logMemory": "cross-env NODE_ICU_DATA=node_modules/full-icu jest --runInBand --logHeapUsage",
"test:unit.ci": "cross-env NODE_ICU_DATA=node_modules/full-icu node --expose-gc ./node_modules/jest/bin/jest --forceExit",
Expand Down
80 changes: 57 additions & 23 deletions src/Lookup/AdvancedFind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,59 +11,93 @@
RawNoErrorScalarValue
} from '../interpreter/InterpreterValue'
import {SimpleRangeValue} from '../SimpleRangeValue'
import {SearchOptions} from './SearchStrategy'
import {AdvancedFindOptions, SearchOptions} from './SearchStrategy'
import {forceNormalizeString} from '../interpreter/ArithmeticHelper'
import {findLastOccurrenceInOrderedRange} from '../interpreter/binarySearch'
import {compare, findLastOccurrenceInOrderedRange} from '../interpreter/binarySearch'

const NOT_FOUND = -1

export abstract class AdvancedFind {
protected constructor(
protected dependencyGraph: DependencyGraph
) {
}

public advancedFind(keyMatcher: (arg: RawInterpreterValue) => boolean, rangeValue: SimpleRangeValue): number {
let values: InternalScalarValue[]
public advancedFind(keyMatcher: (arg: RawInterpreterValue) => boolean, rangeValue: SimpleRangeValue, { returnOccurence }: AdvancedFindOptions = { returnOccurence: 'first' }): number {
budnix marked this conversation as resolved.
Show resolved Hide resolved
const range = rangeValue.range
if (range === undefined) {
values = rangeValue.valuesFromTopLeftCorner()
} else {
values = this.dependencyGraph.computeListOfValuesInRange(range)
}
for (let i = 0; i < values.length; i++) {
const values: InternalScalarValue[] = (range === undefined)
? rangeValue.valuesFromTopLeftCorner()

Check warning on line 29 in src/Lookup/AdvancedFind.ts

View check run for this annotation

Codecov / codecov/patch

src/Lookup/AdvancedFind.ts#L29

Added line #L29 was not covered by tests
: this.dependencyGraph.computeListOfValuesInRange(range)

const initialIterationIndex = returnOccurence === 'first' ? 0 : values.length-1
const iterationCondition = returnOccurence === 'first' ? (i: number) => i < values.length : (i: number) => i >= 0
const incrementIndex = returnOccurence === 'first' ? (i: number) => i+1 : (i: number) => i-1

for (let i = initialIterationIndex; iterationCondition(i); i = incrementIndex(i)) {
if (keyMatcher(getRawValue(values[i]))) {
return i
}
}
return -1
return NOT_FOUND
}

/*
* WARNING: Finding lower/upper bounds in unordered ranges is not supported. When ordering === 'none', assumes matchExactly === true
*/
protected basicFind(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, searchCoordinate: 'col' | 'row', { ordering, matchExactly }: SearchOptions): number {
protected basicFind(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, searchCoordinate: 'col' | 'row', { ordering, ifNoMatch, returnOccurence }: SearchOptions): number {
const normalizedSearchKey = typeof searchKey === 'string' ? forceNormalizeString(searchKey) : searchKey
const range = rangeValue.range

if (range === undefined) {
return this.findNormalizedValue(normalizedSearchKey, rangeValue.valuesFromTopLeftCorner())
return this.findNormalizedValue(normalizedSearchKey, rangeValue.valuesFromTopLeftCorner(), ifNoMatch, returnOccurence)
}

if (ordering === 'none') {
return this.findNormalizedValue(normalizedSearchKey, this.dependencyGraph.computeListOfValuesInRange(range))
return this.findNormalizedValue(normalizedSearchKey, this.dependencyGraph.computeListOfValuesInRange(range), ifNoMatch, returnOccurence)
}

return findLastOccurrenceInOrderedRange(
normalizedSearchKey,
range,
{ searchCoordinate, orderingDirection: ordering, matchExactly },
{ searchCoordinate, orderingDirection: ordering, ifNoMatch },
this.dependencyGraph
)
}

protected findNormalizedValue(searchKey: RawNoErrorScalarValue, searchArray: InternalScalarValue[]): number {
return searchArray
.map(getRawValue)
.map(val => typeof val === 'string' ? forceNormalizeString(val) : val)
.indexOf(searchKey)
protected findNormalizedValue(searchKey: RawNoErrorScalarValue, searchArray: InternalScalarValue[], ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound' = 'returnNotFound', returnOccurence: 'first' | 'last' = 'first'): number {
const normalizedArray = searchArray
.map(getRawValue)
.map(val => typeof val === 'string' ? forceNormalizeString(val) : val)

if (ifNoMatch === 'returnNotFound') {
return returnOccurence === 'first' ? normalizedArray.indexOf(searchKey) : normalizedArray.lastIndexOf(searchKey)
}

const compareFn = ifNoMatch === 'returnLowerBound'
? (left: RawNoErrorScalarValue, right: RawInterpreterValue) => compare(left, right)
: (left: RawNoErrorScalarValue, right: RawInterpreterValue) => -compare(left, right)

let bestValue: RawNoErrorScalarValue = ifNoMatch === 'returnLowerBound' ? -Infinity : Infinity
let bestIndex = NOT_FOUND

const initialIterationIndex = returnOccurence === 'first' ? 0 : normalizedArray.length-1
const iterationCondition = returnOccurence === 'first' ? (i: number) => i < normalizedArray.length : (i: number) => i >= 0
const incrementIndex = returnOccurence === 'first' ? (i: number) => i+1 : (i: number) => i-1

for (let i = initialIterationIndex; iterationCondition(i); i = incrementIndex(i)) {
const value = normalizedArray[i] as RawNoErrorScalarValue

if (value === searchKey) {
return i
}

if (compareFn(value, searchKey) > 0) {
continue
}

if (compareFn(bestValue, value) < 0) {
bestValue = value
bestIndex = i
}
}

return bestIndex
}
}
32 changes: 18 additions & 14 deletions src/Lookup/ColumnIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import {ColumnsSpan, RowsSpan} from '../Span'
import {Statistics, StatType} from '../statistics'
import {ColumnBinarySearch} from './ColumnBinarySearch'
import {ColumnSearchStrategy, SearchOptions} from './SearchStrategy'
import {AdvancedFindOptions, ColumnSearchStrategy, SearchOptions} from './SearchStrategy'
import {Maybe} from '../Maybe'
import {AbsoluteCellRange} from '../AbsoluteCellRange'

Expand Down Expand Up @@ -107,16 +107,20 @@
}
}

/*
* WARNING: Finding lower/upper bounds in unordered ranges is not supported. When ordering === 'none', assumes matchExactly === true
*/
public find(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, { ordering, matchExactly }: SearchOptions): number {
const handlingDuplicates = matchExactly === true ? 'findFirst' : 'findLast'
const resultUsingColumnIndex = this.findUsingColumnIndex(searchKey, rangeValue, handlingDuplicates)
return resultUsingColumnIndex !== undefined ? resultUsingColumnIndex : this.binarySearchStrategy.find(searchKey, rangeValue, { ordering, matchExactly })
public find(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, { ordering, ifNoMatch, returnOccurence }: SearchOptions): number {
sequba marked this conversation as resolved.
Show resolved Hide resolved
if (ifNoMatch == null) {
ifNoMatch = 'returnNotFound'

Check warning on line 112 in src/Lookup/ColumnIndex.ts

View check run for this annotation

Codecov / codecov/patch

src/Lookup/ColumnIndex.ts#L112

Added line #L112 was not covered by tests
}

if (returnOccurence == null) {
returnOccurence = ordering === 'none' ? 'first' : 'last'
}

const resultUsingColumnIndex = this.findUsingColumnIndex(searchKey, rangeValue, returnOccurence)
return resultUsingColumnIndex !== undefined ? resultUsingColumnIndex : this.binarySearchStrategy.find(searchKey, rangeValue, { ordering, ifNoMatch, returnOccurence })
}

private findUsingColumnIndex(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, handlingDuplicates: 'findFirst' | 'findLast'): Maybe<number> {
private findUsingColumnIndex(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, returnOccurence: 'first' | 'last'): Maybe<number> {
sequba marked this conversation as resolved.
Show resolved Hide resolved
const range = rangeValue.range
if (range === undefined) {
return undefined
Expand All @@ -135,15 +139,15 @@
return undefined
}

const rowNumber = ColumnIndex.findRowBelongingToRange(valueIndexForTheKey, range, handlingDuplicates)
const rowNumber = ColumnIndex.findRowBelongingToRange(valueIndexForTheKey, range, returnOccurence)
return rowNumber !== undefined ? rowNumber - range.start.row : undefined
}

private static findRowBelongingToRange(valueIndex: ValueIndex, range: AbsoluteCellRange, handlingDuplicates: 'findFirst' | 'findLast'): Maybe<number> {
private static findRowBelongingToRange(valueIndex: ValueIndex, range: AbsoluteCellRange, returnOccurence: 'first' | 'last'): Maybe<number> {
sequba marked this conversation as resolved.
Show resolved Hide resolved
const start = range.start.row
const end = range.end.row

const positionInIndex = handlingDuplicates === 'findFirst'
const positionInIndex = returnOccurence === 'first'
? findInOrderedArray(start, valueIndex.index, 'upperBound')
: findInOrderedArray(end, valueIndex.index, 'lowerBound')

Expand All @@ -158,8 +162,8 @@
}


public advancedFind(keyMatcher: (arg: RawInterpreterValue) => boolean, range: SimpleRangeValue): number {
return this.binarySearchStrategy.advancedFind(keyMatcher, range)
public advancedFind(keyMatcher: (arg: RawInterpreterValue) => boolean, range: SimpleRangeValue, options: AdvancedFindOptions = { returnOccurence: 'first' }): number {
sequba marked this conversation as resolved.
Show resolved Hide resolved
return this.binarySearchStrategy.advancedFind(keyMatcher, range, options)
}

public addColumns(columnsSpan: ColumnsSpan) {
Expand Down
9 changes: 7 additions & 2 deletions src/Lookup/SearchStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import {ColumnIndex} from './ColumnIndex'

export interface SearchOptions {
ordering: 'asc' | 'desc' | 'none',
matchExactly?: boolean,
ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound',
returnOccurence?: 'first' | 'last',
}

export interface AdvancedFindOptions {
returnOccurence?: 'first' | 'last',
sequba marked this conversation as resolved.
Show resolved Hide resolved
}

export interface SearchStrategy {
Expand All @@ -25,7 +30,7 @@ export interface SearchStrategy {
*/
find(searchKey: RawNoErrorScalarValue, range: SimpleRangeValue, options: SearchOptions): number,

advancedFind(keyMatcher: (arg: RawInterpreterValue) => boolean, range: SimpleRangeValue): number,
advancedFind(keyMatcher: (arg: RawInterpreterValue) => boolean, range: SimpleRangeValue, options: AdvancedFindOptions): number,
}

export interface ColumnSearchStrategy extends SearchStrategy {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/csCZ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = {
WEEKNUM: 'WEEKNUM',
WORKDAY: 'WORKDAY',
'WORKDAY.INTL': 'WORKDAY.INTL',
XLOOKUP: 'XVYHLEDAT',
XNPV: 'XNPV',
XOR: 'XOR',
YEAR: 'ROK',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/daDK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = {
WEEKNUM: 'UGE.NR',
WORKDAY: 'ARBEJDSDAG',
'WORKDAY.INTL': 'ARBEJDSDAG.INTL',
XLOOKUP: 'XOPSLAG',
XNPV: 'NETTO.NUTIDSVÆRDI',
XOR: 'XELLER',
YEAR: 'ÅR',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/deDE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ const dictionary: RawTranslationPackage = {
WEEKNUM: 'KALENDERWOCHE',
WORKDAY: 'ARBEITSTAG',
'WORKDAY.INTL': 'ARBEITSTAG.INTL',
XLOOKUP: 'XVERWEIS',
XNPV: 'XKAPITALWERT',
XOR: 'XODER',
YEAR: 'JAHR',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/enGB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ const dictionary: RawTranslationPackage = {
'WORKDAY.INTL': 'WORKDAY.INTL',
XNPV: 'XNPV',
XOR: 'XOR',
XLOOKUP: 'XLOOKUP',
YEAR: 'YEAR',
YEARFRAC: 'YEARFRAC',
'HF.ADD': 'HF.ADD',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/esES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export const dictionary: RawTranslationPackage = {
WEEKNUM: 'NUM.DE.SEMANA',
WORKDAY: 'DIA.LAB',
'WORKDAY.INTL': 'DIA.LAB.INTL',
XLOOKUP: 'BUSCARX',
XNPV: 'VNA.NO.PER',
XOR: 'XOR',
YEAR: 'AÑO',
Expand Down
Loading
Loading