Skip to content

Commit 2deb046

Browse files
RobbyMeyersMariusWirtz
authored andcommitted
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 d352382 commit 2deb046

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
@@ -402,6 +402,9 @@ def get_elements_dataframe(
402402
allow_empty_alias: bool = True,
403403
attribute_suffix: bool = False,
404404
element_type_column: str = "Type",
405+
element_type: Optional[Union[int, str, "Element.Types", Iterable]] = None,
406+
name_pattern: Optional[str] = None,
407+
level: Optional[int] = None,
405408
**kwargs,
406409
) -> "pd.DataFrame":
407410
"""
@@ -420,6 +423,16 @@ def get_elements_dataframe(
420423
:param allow_empty_alias: False if empty alias values should be substituted with element names instead
421424
:param attribute_suffix: True if attribute columns should have ':a', ':s' or ':n' suffix
422425
:param element_type_column: The column name in the df which specifies which element is which type.
426+
:param element_type: Restrict to elements of the given type(s). Accepts an
427+
``Element.Types`` enum value, a string ('numeric'/'string'/'consolidated',
428+
case-insensitive), an int (1/2/3), or an iterable of any of those.
429+
Only applied when ``elements`` is None. When explicitly set, overrides
430+
``skip_consolidations``.
431+
:param name_pattern: Restrict to elements whose name matches the glob pattern
432+
(``*`` wildcard, case- and space-insensitive). Only applied when ``elements``
433+
is None.
434+
:param level: Restrict to elements at the given hierarchy level (0 = leaf).
435+
Only applied when ``elements`` is None.
423436
:return: pandas DataFrame
424437
"""
425438

@@ -438,10 +451,39 @@ def get_elements_dataframe(
438451
unique_name = record[0][0]["UniqueName"]
439452
dimension_name, hierarchy_name, _ = dimension_hierarchy_element_tuple_from_unique_name(unique_name)
440453

454+
trio_filter_active = element_type is not None or name_pattern is not None or level is not None
441455
if elements is None or not any(elements):
442-
elements = f"{{ [{dimension_name}].[{hierarchy_name}].Members }}"
443-
if skip_consolidations:
444-
elements = f"{{ Tm1FilterByLevel({elements}, 0) }}"
456+
if trio_filter_active:
457+
# Trio filter explicitly set. Resolve to a concrete element list via the
458+
# filtered get_element_names path. The trio is authoritative and overrides
459+
# skip_consolidations.
460+
resolved = self.get_element_names(
461+
dimension_name=dimension_name,
462+
hierarchy_name=hierarchy_name,
463+
element_type=element_type,
464+
name_pattern=name_pattern,
465+
level=level,
466+
)
467+
if resolved:
468+
elements = (
469+
"{" + ",".join(f"[{dimension_name}].[{hierarchy_name}].[{member}]" for member in resolved) + "}"
470+
)
471+
else:
472+
# Empty match. Filter the full Members set against an
473+
# unreachably high level so the MDX produces zero rows but the
474+
# downstream pipeline still emits the full column schema
475+
# (dimension name, attributes, levels, parents). A bare "{}"
476+
# axis would lose the dimension column and break the final
477+
# pd.merge on dimension_name.
478+
empty_set_level = 9999
479+
elements = (
480+
f"{{ Tm1FilterByLevel({{ [{dimension_name}].[{hierarchy_name}].Members }}, "
481+
f"{empty_set_level}) }}"
482+
)
483+
else:
484+
elements = f"{{ [{dimension_name}].[{hierarchy_name}].Members }}"
485+
if skip_consolidations:
486+
elements = f"{{ Tm1FilterByLevel({elements}, 0) }}"
445487

446488
if not isinstance(elements, str):
447489
if isinstance(elements, Iterable):
@@ -461,8 +503,13 @@ def get_elements_dataframe(
461503
)
462504
]
463505

506+
# When the trio filter is active, the resolved element list is authoritative.
507+
# Fetch the full type lookup so consolidated members survive the inner-join below.
508+
element_types_skip_consolidations = False if trio_filter_active else skip_consolidations
464509
element_types = self.get_element_types(
465-
dimension_name=dimension_name, hierarchy_name=hierarchy_name, skip_consolidations=skip_consolidations
510+
dimension_name=dimension_name,
511+
hierarchy_name=hierarchy_name,
512+
skip_consolidations=element_types_skip_consolidations,
466513
)
467514

468515
df = pd.DataFrame(
@@ -838,43 +885,33 @@ def get_all_leaf_element_identifiers(
838885
return self.get_element_identifiers(dimension_name, hierarchy_name, mdx_elements, **kwargs)
839886

840887
def get_elements_by_level(self, dimension_name: str, hierarchy_name: str, level: int, **kwargs) -> List[str]:
841-
"""Get all element names by level in a hierarchy
888+
"""Get all element names by level in a hierarchy.
842889
843890
:param dimension_name: Name of the dimension
844891
:param hierarchy_name: Name of the hierarchy
845892
:param level: Level to filter
846893
:return: List of element names
847894
"""
848-
url = format_url(
849-
"/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name&$filter=Level eq {}",
850-
dimension_name,
851-
hierarchy_name,
852-
str(level),
853-
)
854-
response = self._rest.GET(url, **kwargs)
855-
return [e["Name"] for e in response.json()["value"]]
895+
return self.get_element_names(dimension_name, hierarchy_name, level=level, **kwargs)
856896

857897
def get_elements_filtered_by_wildcard(
858898
self, dimension_name: str, hierarchy_name: str, wildcard: str, level: int = None, **kwargs
859899
) -> List[str]:
860-
"""Get all element names filtered by wildcard (CaseAndSpaceInsensitive) and level in a hierarchy
900+
"""Get all element names filtered by wildcard (case- and space-insensitive contains) and optional level.
861901
862902
:param dimension_name: Name of the dimension
863903
:param hierarchy_name: Name of the hierarchy
864-
:param wildcard: wildcard to filter
865-
:param level: Level to filter
904+
:param wildcard: substring to match (case- and space-insensitive contains)
905+
:param level: Optional level to filter
866906
:return: List of element names
867907
"""
868-
filter_elements = format_url("contains(tolower(replace(Name,' ','')),tolower(replace('{}',' ', '')))", wildcard)
869-
if level is not None:
870-
filter_elements = filter_elements + f" and Level eq {level}"
871-
url = format_url(
872-
"/Dimensions('{}')/Hierarchies('{}')/Elements?$select=Name&$filter=" + filter_elements,
908+
return self.get_element_names(
873909
dimension_name,
874910
hierarchy_name,
911+
name_pattern=f"*{wildcard}*",
912+
level=level,
913+
**kwargs,
875914
)
876-
response = self._rest.GET(url, **kwargs)
877-
return [e["Name"] for e in response.json()["value"]]
878915

879916
def get_all_element_identifiers(
880917
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

@@ -1843,6 +1844,10 @@ def setUp(self):
18431844
h.add_edge("Total Regions", "Region North", 1)
18441845
h.add_edge("Total Regions", "Region South", 1)
18451846

1847+
# Add a placeholder attribute so the }ElementAttributes_<dim> cube is
1848+
# created. get_elements_dataframe requires this cube to exist.
1849+
h.add_element_attribute("Description", "String")
1850+
18461851
d.add_hierarchy(h)
18471852
self.tm1.dimensions.update_or_create(d)
18481853
self.addCleanup(self._cleanup_dimension)
@@ -2064,6 +2069,156 @@ def test_elements_quote_escape(self):
20642069
self.assertEqual([e.name for e in elements], ["O'Brien"])
20652070
self.assertEqual(elements[0].element_type, Element.Types.NUMERIC)
20662071

2072+
# ------------------------------------------------------------------
2073+
# Regression: verify behavior of typed methods is preserved after they
2074+
# are refactored to delegate to get_element_names. Snapshots in
2075+
# Tests/fixtures/element_filtering_snapshots/ were generated against
2076+
# master before the refactor.
2077+
# ------------------------------------------------------------------
2078+
2079+
SNAPSHOT_DIR = Path(__file__).parent / "fixtures" / "element_filtering_snapshots"
2080+
2081+
def _load_snapshot(self, name):
2082+
path = self.SNAPSHOT_DIR / name
2083+
if not path.exists():
2084+
self.fail(
2085+
f"Snapshot '{name}' not found at {self.SNAPSHOT_DIR}. "
2086+
f"Regenerate by re-running the snapshot generator from the plan's "
2087+
f"Phase 3 / Task 3.1."
2088+
)
2089+
with open(path) as f:
2090+
return json.load(f)
2091+
2092+
def test_regression_by_level_0(self):
2093+
actual = self.tm1.elements.get_elements_by_level(self.dimension_name, self.hierarchy_name, level=0)
2094+
expected = self._load_snapshot("by_level_0.json")
2095+
self.assertEqual(sorted(actual), expected)
2096+
2097+
def test_regression_by_level_1(self):
2098+
actual = self.tm1.elements.get_elements_by_level(self.dimension_name, self.hierarchy_name, level=1)
2099+
expected = self._load_snapshot("by_level_1.json")
2100+
self.assertEqual(sorted(actual), expected)
2101+
2102+
def test_regression_by_level_2(self):
2103+
actual = self.tm1.elements.get_elements_by_level(self.dimension_name, self.hierarchy_name, level=2)
2104+
expected = self._load_snapshot("by_level_2.json")
2105+
self.assertEqual(sorted(actual), expected)
2106+
2107+
def test_regression_wildcard_cases(self):
2108+
"""Verify get_elements_filtered_by_wildcard preserves case+space-insensitive contains."""
2109+
for i in range(5):
2110+
snap = self._load_snapshot(f"wildcard_{i}.json")
2111+
actual = self.tm1.elements.get_elements_filtered_by_wildcard(
2112+
self.dimension_name,
2113+
self.hierarchy_name,
2114+
wildcard=snap["wildcard"],
2115+
level=snap["level"],
2116+
)
2117+
self.assertEqual(
2118+
sorted(actual),
2119+
snap["result"],
2120+
msg=(
2121+
f"wildcard_{i}: wildcard={snap['wildcard']!r} level={snap['level']}, "
2122+
f"got {sorted(actual)!r}, expected {snap['result']!r}"
2123+
),
2124+
)
2125+
2126+
# ------------------------------------------------------------------
2127+
# get_elements_dataframe with trio kwargs
2128+
# ------------------------------------------------------------------
2129+
2130+
@skip_if_no_pandas
2131+
def test_dataframe_element_type_numeric(self):
2132+
df = self.tm1.elements.get_elements_dataframe(
2133+
self.dimension_name,
2134+
self.hierarchy_name,
2135+
element_type="numeric",
2136+
skip_consolidations=False,
2137+
)
2138+
names = set(df[self.dimension_name].tolist())
2139+
self.assertEqual(names, {"Numeric A", "Numeric B", "Numeric C", "O'Brien"})
2140+
2141+
@skip_if_no_pandas
2142+
def test_dataframe_pattern(self):
2143+
df = self.tm1.elements.get_elements_dataframe(
2144+
self.dimension_name,
2145+
self.hierarchy_name,
2146+
name_pattern="Region*",
2147+
)
2148+
names = set(df[self.dimension_name].tolist())
2149+
self.assertEqual(names, {"Region North", "Region South"})
2150+
2151+
@skip_if_no_pandas
2152+
def test_dataframe_level(self):
2153+
df = self.tm1.elements.get_elements_dataframe(
2154+
self.dimension_name,
2155+
self.hierarchy_name,
2156+
level=0,
2157+
skip_consolidations=False,
2158+
)
2159+
names = set(df[self.dimension_name].tolist())
2160+
self.assertEqual(
2161+
names,
2162+
{"Numeric A", "Numeric B", "Numeric C", "O'Brien", "String A", "String B"},
2163+
)
2164+
2165+
@skip_if_no_pandas
2166+
def test_dataframe_trio_composed(self):
2167+
df = self.tm1.elements.get_elements_dataframe(
2168+
self.dimension_name,
2169+
self.hierarchy_name,
2170+
element_type="numeric",
2171+
name_pattern="*A*",
2172+
level=0,
2173+
)
2174+
names = set(df[self.dimension_name].tolist())
2175+
self.assertEqual(names, {"Numeric A"})
2176+
2177+
@skip_if_no_pandas
2178+
def test_dataframe_element_type_overrides_skip_consolidations(self):
2179+
"""When element_type is explicitly set, skip_consolidations is ignored
2180+
(documented in docstring)."""
2181+
df = self.tm1.elements.get_elements_dataframe(
2182+
self.dimension_name,
2183+
self.hierarchy_name,
2184+
element_type=["numeric", "consolidated"],
2185+
skip_consolidations=True, # would normally drop consolidations
2186+
)
2187+
names = set(df[self.dimension_name].tolist())
2188+
# Consolidations should be present despite skip_consolidations=True
2189+
self.assertIn("Region North", names)
2190+
self.assertIn("Region South", names)
2191+
self.assertIn("Total Regions", names)
2192+
2193+
@skip_if_no_pandas
2194+
def test_dataframe_regression_no_filter(self):
2195+
"""Without trio kwargs, get_elements_dataframe matches the snapshot from master."""
2196+
import pandas as pd
2197+
2198+
snapshot = pd.read_csv(self.SNAPSHOT_DIR / "dataframe_default.csv")
2199+
df = self.tm1.elements.get_elements_dataframe(self.dimension_name, self.hierarchy_name)
2200+
# Snapshot's first column is the snapshot's dimension name; the test's
2201+
# df uses a different dimension name. Compare row sets on element name + type.
2202+
snap_first = snapshot.columns[0]
2203+
df_first = df.columns[0]
2204+
snap_rows = sorted(zip(snapshot[snap_first].tolist(), snapshot["Type"].tolist()))
2205+
df_rows = sorted(zip(df[df_first].tolist(), df["Type"].tolist()))
2206+
self.assertEqual(snap_rows, df_rows)
2207+
2208+
@skip_if_no_pandas
2209+
def test_dataframe_trio_empty_match_preserves_schema(self):
2210+
"""When the trio filter matches zero elements, the returned DataFrame must
2211+
still carry the full column schema (attributes, levels, parents) so callers
2212+
relying on df['<attr>'] don't see KeyError."""
2213+
df_full = self.tm1.elements.get_elements_dataframe(self.dimension_name, self.hierarchy_name)
2214+
df_empty = self.tm1.elements.get_elements_dataframe(
2215+
self.dimension_name,
2216+
self.hierarchy_name,
2217+
name_pattern="NonExistentNameThatMatchesNothing*",
2218+
)
2219+
self.assertEqual(list(df_full.columns), list(df_empty.columns))
2220+
self.assertEqual(len(df_empty), 0)
2221+
20672222

20682223
if __name__ == "__main__":
20692224
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)