Skip to content

Commit 4b0927d

Browse files
committed
refactor: route typed element methods through new filter helpers; add trio kwargs to get_elements_dataframe
get_elements_by_level and get_elements_filtered_by_wildcard are now thin delegations to get_element_names with the appropriate kwargs. Behavior is preserved (verified by regression tests against snapshots captured from master before the refactor). Net effect: single source of truth for OData $filter construction across the four element-listing methods. get_elements_dataframe gains element_type / name_pattern / level kwargs. When any of the trio is set while elements is None, the method resolves the selection via get_element_names and feeds it into the existing MDX path. The trio is authoritative and overrides skip_consolidations (documented in the docstring).
1 parent 9fda566 commit 4b0927d

11 files changed

Lines changed: 276 additions & 23 deletions

File tree

TM1py/Services/ElementService.py

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,9 @@ def get_elements_dataframe(
371371
allow_empty_alias: bool = True,
372372
attribute_suffix: bool = False,
373373
element_type_column: str = "Type",
374+
element_type: Optional[Union[int, str, "Element.Types", Iterable]] = None,
375+
name_pattern: Optional[str] = None,
376+
level: Optional[int] = None,
374377
**kwargs,
375378
) -> "pd.DataFrame":
376379
"""
@@ -389,6 +392,16 @@ def get_elements_dataframe(
389392
:param allow_empty_alias: False if empty alias values should be substituted with element names instead
390393
:param attribute_suffix: True if attribute columns should have ':a', ':s' or ':n' suffix
391394
:param element_type_column: The column name in the df which specifies which element is which type.
395+
:param element_type: Restrict to elements of the given type(s). Accepts an
396+
``Element.Types`` enum value, a string ('numeric'/'string'/'consolidated',
397+
case-insensitive), an int (1/2/3), or an iterable of any of those.
398+
Only applied when ``elements`` is None. When explicitly set, overrides
399+
``skip_consolidations``.
400+
:param name_pattern: Restrict to elements whose name matches the glob pattern
401+
(``*`` wildcard, case- and space-insensitive). Only applied when ``elements``
402+
is None.
403+
:param level: Restrict to elements at the given hierarchy level (0 = leaf).
404+
Only applied when ``elements`` is None.
392405
:return: pandas DataFrame
393406
"""
394407

@@ -407,10 +420,39 @@ def get_elements_dataframe(
407420
unique_name = record[0][0]["UniqueName"]
408421
dimension_name, hierarchy_name, _ = dimension_hierarchy_element_tuple_from_unique_name(unique_name)
409422

423+
trio_filter_active = element_type is not None or name_pattern is not None or level is not None
410424
if elements is None or not any(elements):
411-
elements = f"{{ [{dimension_name}].[{hierarchy_name}].Members }}"
412-
if skip_consolidations:
413-
elements = f"{{ Tm1FilterByLevel({elements}, 0) }}"
425+
if trio_filter_active:
426+
# Trio filter explicitly set. Resolve to a concrete element list via the
427+
# filtered get_element_names path. The trio is authoritative and overrides
428+
# skip_consolidations.
429+
resolved = self.get_element_names(
430+
dimension_name=dimension_name,
431+
hierarchy_name=hierarchy_name,
432+
element_type=element_type,
433+
name_pattern=name_pattern,
434+
level=level,
435+
)
436+
if resolved:
437+
elements = (
438+
"{" + ",".join(f"[{dimension_name}].[{hierarchy_name}].[{member}]" for member in resolved) + "}"
439+
)
440+
else:
441+
# Empty match. Filter the full Members set against an
442+
# unreachably high level so the MDX produces zero rows but the
443+
# downstream pipeline still emits the full column schema
444+
# (dimension name, attributes, levels, parents). A bare "{}"
445+
# axis would lose the dimension column and break the final
446+
# pd.merge on dimension_name.
447+
empty_set_level = 9999
448+
elements = (
449+
f"{{ Tm1FilterByLevel({{ [{dimension_name}].[{hierarchy_name}].Members }}, "
450+
f"{empty_set_level}) }}"
451+
)
452+
else:
453+
elements = f"{{ [{dimension_name}].[{hierarchy_name}].Members }}"
454+
if skip_consolidations:
455+
elements = f"{{ Tm1FilterByLevel({elements}, 0) }}"
414456

415457
if not isinstance(elements, str):
416458
if isinstance(elements, Iterable):
@@ -430,8 +472,13 @@ def get_elements_dataframe(
430472
)
431473
]
432474

475+
# When the trio filter is active, the resolved element list is authoritative.
476+
# Fetch the full type lookup so consolidated members survive the inner-join below.
477+
element_types_skip_consolidations = False if trio_filter_active else skip_consolidations
433478
element_types = self.get_element_types(
434-
dimension_name=dimension_name, hierarchy_name=hierarchy_name, skip_consolidations=skip_consolidations
479+
dimension_name=dimension_name,
480+
hierarchy_name=hierarchy_name,
481+
skip_consolidations=element_types_skip_consolidations,
435482
)
436483

437484
df = pd.DataFrame(
@@ -807,43 +854,33 @@ def get_all_leaf_element_identifiers(
807854
return self.get_element_identifiers(dimension_name, hierarchy_name, mdx_elements, **kwargs)
808855

809856
def get_elements_by_level(self, dimension_name: str, hierarchy_name: str, level: int, **kwargs) -> List[str]:
810-
"""Get all element names by level in a hierarchy
857+
"""Get all element names by level in a hierarchy.
811858
812859
:param dimension_name: Name of the dimension
813860
:param hierarchy_name: Name of the hierarchy
814861
:param level: Level to filter
815862
:return: List of element names
816863
"""
817-
url = format_url(
818-
"/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name&$filter=Level eq {}",
819-
dimension_name,
820-
hierarchy_name,
821-
str(level),
822-
)
823-
response = self._rest.GET(url, **kwargs)
824-
return [e["Name"] for e in response.json()["value"]]
864+
return self.get_element_names(dimension_name, hierarchy_name, level=level, **kwargs)
825865

826866
def get_elements_filtered_by_wildcard(
827867
self, dimension_name: str, hierarchy_name: str, wildcard: str, level: int = None, **kwargs
828868
) -> List[str]:
829-
"""Get all element names filtered by wildcard (CaseAndSpaceInsensitive) and level in a hierarchy
869+
"""Get all element names filtered by wildcard (case- and space-insensitive contains) and optional level.
830870
831871
:param dimension_name: Name of the dimension
832872
:param hierarchy_name: Name of the hierarchy
833-
:param wildcard: wildcard to filter
834-
:param level: Level to filter
873+
:param wildcard: substring to match (case- and space-insensitive contains)
874+
:param level: Optional level to filter
835875
:return: List of element names
836876
"""
837-
filter_elements = format_url("contains(tolower(replace(Name,' ','')),tolower(replace('{}',' ', '')))", wildcard)
838-
if level is not None:
839-
filter_elements = filter_elements + f" and Level eq {level}"
840-
url = format_url(
841-
"/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name&$filter=" + filter_elements,
877+
return self.get_element_names(
842878
dimension_name,
843879
hierarchy_name,
880+
name_pattern=f"*{wildcard}*",
881+
level=level,
882+
**kwargs,
844883
)
845-
response = self._rest.GET(url, **kwargs)
846-
return [e["Name"] for e in response.json()["value"]]
847884

848885
def get_all_element_identifiers(
849886
self, dimension_name: str, hierarchy_name: str, **kwargs

Tests/ElementService_test.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import configparser
22
import copy
3+
import json
34
import unittest
45
from pathlib import Path
56

@@ -1668,6 +1669,10 @@ def setUp(self):
16681669
h.add_edge("Total Regions", "Region North", 1)
16691670
h.add_edge("Total Regions", "Region South", 1)
16701671

1672+
# Add a placeholder attribute so the }ElementAttributes_<dim> cube is
1673+
# created. get_elements_dataframe requires this cube to exist.
1674+
h.add_element_attribute("Description", "String")
1675+
16711676
d.add_hierarchy(h)
16721677
self.tm1.dimensions.update_or_create(d)
16731678
self.addCleanup(self._cleanup_dimension)
@@ -1889,6 +1894,156 @@ def test_elements_quote_escape(self):
18891894
self.assertEqual([e.name for e in elements], ["O'Brien"])
18901895
self.assertEqual(elements[0].element_type, Element.Types.NUMERIC)
18911896

1897+
# ------------------------------------------------------------------
1898+
# Regression: verify behavior of typed methods is preserved after they
1899+
# are refactored to delegate to get_element_names. Snapshots in
1900+
# Tests/fixtures/element_filtering_snapshots/ were generated against
1901+
# master before the refactor.
1902+
# ------------------------------------------------------------------
1903+
1904+
SNAPSHOT_DIR = Path(__file__).parent / "fixtures" / "element_filtering_snapshots"
1905+
1906+
def _load_snapshot(self, name):
1907+
path = self.SNAPSHOT_DIR / name
1908+
if not path.exists():
1909+
self.fail(
1910+
f"Snapshot '{name}' not found at {self.SNAPSHOT_DIR}. "
1911+
f"Regenerate by re-running the snapshot generator from the plan's "
1912+
f"Phase 3 / Task 3.1."
1913+
)
1914+
with open(path) as f:
1915+
return json.load(f)
1916+
1917+
def test_regression_by_level_0(self):
1918+
actual = self.tm1.elements.get_elements_by_level(self.dimension_name, self.hierarchy_name, level=0)
1919+
expected = self._load_snapshot("by_level_0.json")
1920+
self.assertEqual(sorted(actual), expected)
1921+
1922+
def test_regression_by_level_1(self):
1923+
actual = self.tm1.elements.get_elements_by_level(self.dimension_name, self.hierarchy_name, level=1)
1924+
expected = self._load_snapshot("by_level_1.json")
1925+
self.assertEqual(sorted(actual), expected)
1926+
1927+
def test_regression_by_level_2(self):
1928+
actual = self.tm1.elements.get_elements_by_level(self.dimension_name, self.hierarchy_name, level=2)
1929+
expected = self._load_snapshot("by_level_2.json")
1930+
self.assertEqual(sorted(actual), expected)
1931+
1932+
def test_regression_wildcard_cases(self):
1933+
"""Verify get_elements_filtered_by_wildcard preserves case+space-insensitive contains."""
1934+
for i in range(5):
1935+
snap = self._load_snapshot(f"wildcard_{i}.json")
1936+
actual = self.tm1.elements.get_elements_filtered_by_wildcard(
1937+
self.dimension_name,
1938+
self.hierarchy_name,
1939+
wildcard=snap["wildcard"],
1940+
level=snap["level"],
1941+
)
1942+
self.assertEqual(
1943+
sorted(actual),
1944+
snap["result"],
1945+
msg=(
1946+
f"wildcard_{i}: wildcard={snap['wildcard']!r} level={snap['level']}, "
1947+
f"got {sorted(actual)!r}, expected {snap['result']!r}"
1948+
),
1949+
)
1950+
1951+
# ------------------------------------------------------------------
1952+
# get_elements_dataframe with trio kwargs
1953+
# ------------------------------------------------------------------
1954+
1955+
@skip_if_no_pandas
1956+
def test_dataframe_element_type_numeric(self):
1957+
df = self.tm1.elements.get_elements_dataframe(
1958+
self.dimension_name,
1959+
self.hierarchy_name,
1960+
element_type="numeric",
1961+
skip_consolidations=False,
1962+
)
1963+
names = set(df[self.dimension_name].tolist())
1964+
self.assertEqual(names, {"Numeric A", "Numeric B", "Numeric C", "O'Brien"})
1965+
1966+
@skip_if_no_pandas
1967+
def test_dataframe_pattern(self):
1968+
df = self.tm1.elements.get_elements_dataframe(
1969+
self.dimension_name,
1970+
self.hierarchy_name,
1971+
name_pattern="Region*",
1972+
)
1973+
names = set(df[self.dimension_name].tolist())
1974+
self.assertEqual(names, {"Region North", "Region South"})
1975+
1976+
@skip_if_no_pandas
1977+
def test_dataframe_level(self):
1978+
df = self.tm1.elements.get_elements_dataframe(
1979+
self.dimension_name,
1980+
self.hierarchy_name,
1981+
level=0,
1982+
skip_consolidations=False,
1983+
)
1984+
names = set(df[self.dimension_name].tolist())
1985+
self.assertEqual(
1986+
names,
1987+
{"Numeric A", "Numeric B", "Numeric C", "O'Brien", "String A", "String B"},
1988+
)
1989+
1990+
@skip_if_no_pandas
1991+
def test_dataframe_trio_composed(self):
1992+
df = self.tm1.elements.get_elements_dataframe(
1993+
self.dimension_name,
1994+
self.hierarchy_name,
1995+
element_type="numeric",
1996+
name_pattern="*A*",
1997+
level=0,
1998+
)
1999+
names = set(df[self.dimension_name].tolist())
2000+
self.assertEqual(names, {"Numeric A"})
2001+
2002+
@skip_if_no_pandas
2003+
def test_dataframe_element_type_overrides_skip_consolidations(self):
2004+
"""When element_type is explicitly set, skip_consolidations is ignored
2005+
(documented in docstring)."""
2006+
df = self.tm1.elements.get_elements_dataframe(
2007+
self.dimension_name,
2008+
self.hierarchy_name,
2009+
element_type=["numeric", "consolidated"],
2010+
skip_consolidations=True, # would normally drop consolidations
2011+
)
2012+
names = set(df[self.dimension_name].tolist())
2013+
# Consolidations should be present despite skip_consolidations=True
2014+
self.assertIn("Region North", names)
2015+
self.assertIn("Region South", names)
2016+
self.assertIn("Total Regions", names)
2017+
2018+
@skip_if_no_pandas
2019+
def test_dataframe_regression_no_filter(self):
2020+
"""Without trio kwargs, get_elements_dataframe matches the snapshot from master."""
2021+
import pandas as pd
2022+
2023+
snapshot = pd.read_csv(self.SNAPSHOT_DIR / "dataframe_default.csv")
2024+
df = self.tm1.elements.get_elements_dataframe(self.dimension_name, self.hierarchy_name)
2025+
# Snapshot's first column is the snapshot's dimension name; the test's
2026+
# df uses a different dimension name. Compare row sets on element name + type.
2027+
snap_first = snapshot.columns[0]
2028+
df_first = df.columns[0]
2029+
snap_rows = sorted(zip(snapshot[snap_first].tolist(), snapshot["Type"].tolist()))
2030+
df_rows = sorted(zip(df[df_first].tolist(), df["Type"].tolist()))
2031+
self.assertEqual(snap_rows, df_rows)
2032+
2033+
@skip_if_no_pandas
2034+
def test_dataframe_trio_empty_match_preserves_schema(self):
2035+
"""When the trio filter matches zero elements, the returned DataFrame must
2036+
still carry the full column schema (attributes, levels, parents) so callers
2037+
relying on df['<attr>'] don't see KeyError."""
2038+
df_full = self.tm1.elements.get_elements_dataframe(self.dimension_name, self.hierarchy_name)
2039+
df_empty = self.tm1.elements.get_elements_dataframe(
2040+
self.dimension_name,
2041+
self.hierarchy_name,
2042+
name_pattern="NonExistentNameThatMatchesNothing*",
2043+
)
2044+
self.assertEqual(list(df_full.columns), list(df_empty.columns))
2045+
self.assertEqual(len(df_empty), 0)
2046+
18922047

18932048
if __name__ == "__main__":
18942049
unittest.main()
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[
2+
"Numeric A",
3+
"Numeric B",
4+
"Numeric C",
5+
"O'Brien",
6+
"String A",
7+
"String B"
8+
]
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[
2+
"Region North",
3+
"Region South"
4+
]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[
2+
"Total Regions"
3+
]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
TM1py_snapshot_fixture,Type,Description,level001_Weight,level000_Weight,level001,level000
2+
String A,String,,0.000000,0.000000,,
3+
String B,String,,0.000000,0.000000,,
4+
Numeric A,Numeric,,1.000000,1.000000,Region North,Total Regions
5+
Numeric B,Numeric,,1.000000,1.000000,Region North,Total Regions
6+
Numeric C,Numeric,,1.000000,1.000000,Region South,Total Regions
7+
O'Brien,Numeric,,1.000000,1.000000,Region South,Total Regions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"wildcard": "region",
3+
"level": null,
4+
"result": [
5+
"Region North",
6+
"Region South",
7+
"Total Regions"
8+
]
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"wildcard": "REGION",
3+
"level": null,
4+
"result": [
5+
"Region North",
6+
"Region South",
7+
"Total Regions"
8+
]
9+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"wildcard": "Region North",
3+
"level": null,
4+
"result": [
5+
"Region North"
6+
]
7+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"wildcard": "numeric",
3+
"level": 0,
4+
"result": [
5+
"Numeric A",
6+
"Numeric B",
7+
"Numeric C"
8+
]
9+
}

0 commit comments

Comments
 (0)