diff --git a/TM1py/Objects/DynamicPropertiesMixin.py b/TM1py/Objects/DynamicPropertiesMixin.py new file mode 100644 index 00000000..9929980b --- /dev/null +++ b/TM1py/Objects/DynamicPropertiesMixin.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + +from typing import Dict, Optional + + +class DynamicPropertiesMixin: + """Mixin that adds support for dynamic/extra properties from the TM1 REST API. + + TM1 objects can carry additional properties beyond their known fields. + This mixin provides a common interface to store, filter, and serialize them. + + Subclasses must define ``_DYNAMIC_PROPERTIES_EXCLUDED_KEYS`` as a frozenset + of keys that are already handled explicitly (e.g. "Name", "@odata.type"). + """ + + _DYNAMIC_PROPERTIES_EXCLUDED_KEYS: frozenset = frozenset() + + @property + def dynamic_properties(self) -> Dict: + return self._dynamic_properties + + @dynamic_properties.setter + def dynamic_properties(self, value: Optional[Dict]) -> None: + self._dynamic_properties = value or {} + + @classmethod + def _filter_dynamic_properties(cls, properties: Dict) -> Dict: + """Return a copy of *properties* with all reserved/excluded keys removed.""" + return {k: v for k, v in properties.items() if k not in cls._DYNAMIC_PROPERTIES_EXCLUDED_KEYS} diff --git a/TM1py/Objects/MDXView.py b/TM1py/Objects/MDXView.py index 9ad7c025..341cbac2 100644 --- a/TM1py/Objects/MDXView.py +++ b/TM1py/Objects/MDXView.py @@ -5,29 +5,30 @@ import re from typing import Dict, Optional +from TM1py.Objects.DynamicPropertiesMixin import DynamicPropertiesMixin from TM1py.Objects.View import View from TM1py.Utils import case_and_space_insensitive_equals -MDX_VIEW_EXCLUDED_KEYS = frozenset( - { - "@odata.type", - "@odata.context", - "@odata.etag", - "Name", - "MDX", - "Cube", - "Attributes", - "LocalizedAttributes", - } -) - - -class MDXView(View): + +class MDXView(DynamicPropertiesMixin, View): """Abstraction on TM1 MDX view IMPORTANT. MDXViews can't be seen through the old TM1 clients (Architect, Perspectives). They do exist though! """ + _DYNAMIC_PROPERTIES_EXCLUDED_KEYS = frozenset( + { + "@odata.type", + "@odata.context", + "@odata.etag", + "Name", + "MDX", + "Cube", + "Attributes", + "LocalizedAttributes", + } + ) + def __init__(self, cube_name: str, view_name: str, MDX: str, dynamic_properties: Optional[Dict] = None): View.__init__(self, cube_name, view_name) self._mdx = MDX @@ -50,14 +51,6 @@ def MDX(self) -> str: def MDX(self, value: str): self._mdx = value - @property - def dynamic_properties(self) -> Dict: - return self._dynamic_properties - - @dynamic_properties.setter - def dynamic_properties(self, value: Optional[Dict]) -> None: - self._dynamic_properties = value or {} - @property def body(self) -> str: return self.construct_body() @@ -97,7 +90,7 @@ def from_dict(cls, view_as_dict: Dict, cube_name: str = None) -> "MDXView": cube_name=view_as_dict["Cube"]["Name"] if not cube_name else cube_name, view_name=view_as_dict["Name"], MDX=view_as_dict["MDX"], - dynamic_properties={k: v for k, v in view_as_dict.items() if k not in MDX_VIEW_EXCLUDED_KEYS}, + dynamic_properties=cls._filter_dynamic_properties(view_as_dict), ) def construct_body(self) -> str: @@ -105,5 +98,5 @@ def construct_body(self) -> str: mdx_view_as_dict["@odata.type"] = "ibm.tm1.api.v1.MDXView" mdx_view_as_dict["Name"] = self._name mdx_view_as_dict["MDX"] = self._mdx - mdx_view_as_dict.update({k: v for k, v in self._dynamic_properties.items() if k not in MDX_VIEW_EXCLUDED_KEYS}) + mdx_view_as_dict.update(self._filter_dynamic_properties(self._dynamic_properties)) return json.dumps(mdx_view_as_dict, ensure_ascii=False) diff --git a/TM1py/Objects/NativeView.py b/TM1py/Objects/NativeView.py index ebc03e6e..9876e27b 100644 --- a/TM1py/Objects/NativeView.py +++ b/TM1py/Objects/NativeView.py @@ -6,18 +6,37 @@ from mdxpy import MdxBuilder, MdxHierarchySet, Member from TM1py.Objects.Axis import ViewAxisSelection, ViewTitleSelection +from TM1py.Objects.DynamicPropertiesMixin import DynamicPropertiesMixin from TM1py.Objects.Subset import AnonymousSubset, Subset from TM1py.Objects.View import View from TM1py.Utils import case_and_space_insensitive_equals, read_object_name_from_url -class NativeView(View): +class NativeView(DynamicPropertiesMixin, View): """Abstraction of TM1 NativeView (classic cube view) :Notes: Complete, functional and tested """ + _DYNAMIC_PROPERTIES_EXCLUDED_KEYS = frozenset( + { + "@odata.type", + "@odata.context", + "@odata.etag", + "Name", + "Columns", + "Rows", + "Titles", + "SuppressEmptyColumns", + "SuppressEmptyRows", + "FormatString", + "Cube", + "Attributes", + "LocalizedAttributes", + } + ) + def __init__( self, cube_name: str, @@ -28,6 +47,7 @@ def __init__( titles: Optional[Iterable[ViewTitleSelection]] = None, columns: Optional[Iterable[ViewAxisSelection]] = None, rows: Optional[Iterable[ViewAxisSelection]] = None, + dynamic_properties: Optional[Dict] = None, ): super().__init__(cube_name, view_name) self._suppress_empty_columns = suppress_empty_columns @@ -36,6 +56,8 @@ def __init__( self._titles = list(titles) if titles else [] self._columns = list(columns) if columns else [] self._rows = list(rows) if rows else [] + self._dynamic_properties = {} + self.dynamic_properties = dynamic_properties @property def body(self) -> str: @@ -270,6 +292,7 @@ def from_dict(cls, view_as_dict: Dict, cube_name: str = None) -> "NativeView": titles=titles, columns=columns, rows=rows, + dynamic_properties=cls._filter_dynamic_properties(view_as_dict), ) @classmethod @@ -303,29 +326,19 @@ def _construct_body(self) -> str: :return: string, the valid JSON """ - top_json = '{"@odata.type": "ibm.tm1.api.v1.NativeView","Name": "' + self._name + '",' - columns_json = ",".join([column.body for column in self._columns]) - rows_json = ",".join([row.body for row in self._rows]) - titles_json = ",".join([title.body for title in self._titles]) - bottom_json = ( - '"SuppressEmptyColumns": ' - + str(self._suppress_empty_columns).lower() - + ',"SuppressEmptyRows":' - + str(self._suppress_empty_rows).lower() - + ',"FormatString": "' - + self._format_string - + '"}' - ) - return "".join( - [ - top_json, - '"Columns":[', - columns_json, - '],"Rows":[', - rows_json, - '],"Titles":[', - titles_json, - "],", - bottom_json, - ] - ) + body = { + "@odata.type": "ibm.tm1.api.v1.NativeView", + "Name": self._name, + "Columns": [json.loads(column.body) for column in self._columns], + "Rows": [json.loads(row.body) for row in self._rows], + "Titles": [json.loads(title.body) for title in self._titles], + "SuppressEmptyColumns": self._suppress_empty_columns, + "SuppressEmptyRows": self._suppress_empty_rows, + "FormatString": self._format_string, + } + + dynamic_props = self._filter_dynamic_properties(self._dynamic_properties) + if dynamic_props: + body.update(dynamic_props) + + return json.dumps(body, ensure_ascii=False) diff --git a/TM1py/Services/ElementService.py b/TM1py/Services/ElementService.py index 815d36b8..701cc47a 100644 --- a/TM1py/Services/ElementService.py +++ b/TM1py/Services/ElementService.py @@ -196,7 +196,7 @@ def escape_single_quote(text): [ f"IF(ElementIsParent('{dimension_name}','{hierarchy_name}','{parent}','{child}')=1);", f"HierarchyElementComponentDelete('{dimension_name}','{hierarchy_name}','{parent}','{child}');", - f"ENDIF;", + "ENDIF;", ] ) diff --git a/TM1py/Services/ViewService.py b/TM1py/Services/ViewService.py index 070a46f5..1c196cdc 100644 --- a/TM1py/Services/ViewService.py +++ b/TM1py/Services/ViewService.py @@ -7,7 +7,7 @@ from TM1py.Exceptions.Exceptions import TM1pyRestException from TM1py.Objects import View -from TM1py.Objects.MDXView import MDX_VIEW_EXCLUDED_KEYS, MDXView +from TM1py.Objects.MDXView import MDXView from TM1py.Objects.NativeView import NativeView from TM1py.Services.ObjectService import ObjectService from TM1py.Services.RestService import RestService @@ -65,11 +65,8 @@ def get(self, cube_name: str, view_name: str, private: bool = False, **kwargs) - url = format_url("/Cubes('{}')/{}('{}')?$expand=*", cube_name, view_type, view_name) response = self._rest.GET(url, **kwargs) view_as_dict = response.json() - dynamic_properties = {k: v for k, v in view_as_dict.items() if k not in MDX_VIEW_EXCLUDED_KEYS} if "MDX" in view_as_dict: - return MDXView( - cube_name=cube_name, view_name=view_name, MDX=view_as_dict["MDX"], dynamic_properties=dynamic_properties - ) + return MDXView.from_dict(view_as_dict) else: return self.get_native_view(cube_name=cube_name, view_name=view_name, private=private) diff --git a/Tests/ElementService_test.py b/Tests/ElementService_test.py index bf2a61fd..c2a92fe8 100644 --- a/Tests/ElementService_test.py +++ b/Tests/ElementService_test.py @@ -5,14 +5,18 @@ from mdxpy import MdxBuilder -from TM1py.Exceptions import TM1pyException, TM1pyRestException, TM1pyWritePartialFailureException -from TM1py.Objects import Dimension, Element, ElementAttribute, Hierarchy -from TM1py.Services import TM1Service from Tests.Utils import ( generate_test_uuid, skip_if_no_pandas, skip_if_version_lower_than, ) +from TM1py.Exceptions import ( + TM1pyException, + TM1pyRestException, + TM1pyWritePartialFailureException, +) +from TM1py.Objects import Dimension, Element, ElementAttribute, Hierarchy +from TM1py.Services import TM1Service class TestElementService(unittest.TestCase): @@ -1288,7 +1292,7 @@ def test_delete_edges(self): self.assertNotIn(("Total Years", "1990"), edges) @skip_if_version_lower_than(version="11.4") - def test_delete_edges_use_ti_skip_invalid_edges_true(self): + def test_delete_edges_skip_invalid_edges_true(self): self.tm1.elements.delete_edges( dimension_name=self.dimension_name, hierarchy_name=self.hierarchy_name, diff --git a/Tests/NativeView_test.py b/Tests/NativeView_test.py index 4b576ef2..5b3b3453 100644 --- a/Tests/NativeView_test.py +++ b/Tests/NativeView_test.py @@ -302,3 +302,86 @@ def test_from_dict_with_registered_subset_in_title(self): self.assertEqual("d2", view.columns[0].dimension_name) self.assertEqual("d2", view.columns[0].hierarchy_name) self.assertEqual("{[d2].[e1],[d2].[e2]}", view.columns[0].subset.expression) + + def test_dynamic_properties_default_is_empty_dict(self): + view = NativeView( + cube_name="c1", + view_name="v1", + columns=[ViewAxisSelection("d1", AnonymousSubset("d1", "d1", "{[d1].[e1]}"))], + ) + self.assertEqual({}, view.dynamic_properties) + + def test_dynamic_properties_set_via_constructor(self): + props = {"Meta": {"ExpandAboves": {"[d1].[d1]": False}}} + view = NativeView( + cube_name="c1", + view_name="v1", + columns=[ViewAxisSelection("d1", AnonymousSubset("d1", "d1", "{[d1].[e1]}"))], + dynamic_properties=props, + ) + self.assertEqual(props, view.dynamic_properties) + + def test_dynamic_properties_none_resets_to_empty_dict(self): + view = NativeView( + cube_name="c1", + view_name="v1", + columns=[ViewAxisSelection("d1", AnonymousSubset("d1", "d1", "{[d1].[e1]}"))], + dynamic_properties={"Meta": {"Flag": True}}, + ) + view.dynamic_properties = None + self.assertEqual({}, view.dynamic_properties) + + def test_dynamic_properties_included_in_body(self): + props = {"Meta": {"ExpandAboves": {"[d1].[d1]": False}}} + view = NativeView( + cube_name="c1", + view_name="v1", + columns=[ViewAxisSelection("d1", AnonymousSubset("d1", "d1", "{[d1].[e1]}"))], + dynamic_properties=props, + ) + body = json.loads(view.body) + self.assertIn("Meta", body) + self.assertEqual(props["Meta"], body["Meta"]) + + def test_dynamic_properties_not_in_body_when_empty(self): + view = NativeView( + cube_name="c1", + view_name="v1", + columns=[ViewAxisSelection("d1", AnonymousSubset("d1", "d1", "{[d1].[e1]}"))], + ) + body = json.loads(view.body) + self.assertNotIn("Meta", body) + + def test_dynamic_properties_from_dict(self): + view_dict = { + "@odata.type": "ibm.tm1.api.v1.NativeView", + "@odata.context": "../../$metadata#Cubes('c1')/Views/$entity", + "Name": "v1", + "Columns": [ + {"Subset": {"Hierarchy@odata.bind": "Dimensions('d1')/Hierarchies('d1')", "Expression": "{[d1].[e1]}"}} + ], + "Rows": [], + "Titles": [], + "SuppressEmptyColumns": False, + "SuppressEmptyRows": False, + "FormatString": "0.#########", + "Meta": {"ExpandAboves": {"[d1].[d1]": False}}, + } + view = NativeView.from_dict(view_dict, cube_name="c1") + self.assertIn("Meta", view.dynamic_properties) + self.assertNotIn("Name", view.dynamic_properties) + self.assertNotIn("Columns", view.dynamic_properties) + self.assertNotIn("SuppressEmptyColumns", view.dynamic_properties) + + def test_dynamic_properties_roundtrip(self): + props = {"Meta": {"ExpandAboves": {"[d1].[d1]": False}}} + view = NativeView( + cube_name="c1", + view_name="v1", + columns=[ViewAxisSelection("d1", AnonymousSubset("d1", "d1", "{[d1].[e1]}"))], + dynamic_properties=props, + ) + body = json.loads(view.body) + self.assertEqual(props["Meta"], body["Meta"]) + self.assertEqual("ibm.tm1.api.v1.NativeView", body["@odata.type"]) + self.assertEqual("v1", body["Name"]) diff --git a/Tests/ViewService_test.py b/Tests/ViewService_test.py index 24fd155c..9ad4a68a 100644 --- a/Tests/ViewService_test.py +++ b/Tests/ViewService_test.py @@ -25,6 +25,25 @@ class TestViewService(unittest.TestCase): native_view_name = "TM1py_Tests_Native_View" mdx_view_name = "TM1py_Tests_Mdx_View" mdx_view_with_dynamic_properties_name = "TM1py_Tests_Mdx_View_With_dynamic_properties" + native_view_with_dynamic_properties_name = "TM1py_Tests_Native_View_With_dynamic_properties" + all_view_names = [ + native_view_name, + mdx_view_name, + mdx_view_with_dynamic_properties_name, + native_view_with_dynamic_properties_name, + ] + + @classmethod + def build_dynamic_properties(cls, expanded: bool = False): + return { + "Meta": { + "ExpandAboves": { + f"[{cls.dimension_names[0]}].[{cls.dimension_names[0]}]": expanded, + f"[{cls.dimension_names[1]}].[{cls.dimension_names[1]}]": expanded, + f"[{cls.dimension_names[2]}].[{cls.dimension_names[2]}]": expanded, + } + } + } @classmethod def setUpClass(cls): @@ -55,6 +74,12 @@ def setUpClass(cls): element2 = "Element " + str(random.randint(1, 1000)) element3 = "Element " + str(random.randint(1, 1000)) cellset[(element1, element2, element3)] = random.randint(1, 1000) + + # Add one deterministic cell for the native view update test. + # Before the update, the view includes all members from dimension 0, so this value is visible. + # After the update, dimension 0 is restricted to Elements 1-5, so Element 6 drops out. + # That guarantees a different result even if the randomly generated data contains no matches. + cellset[("Element 6", "Element 123", "Element 1")] = 999999 cls.tm1.cells.write_values(cls.cube_name, cellset) def setUp(self): @@ -99,11 +124,19 @@ def setUp(self): nv_view = self.tm1.views.get_native_view( cube_name=self.cube_name, view_name=self.native_view_name, private=private ) - mdx = nv_view.MDX - mdx_view = MDXView(cube_name=self.cube_name, view_name=self.mdx_view_name, MDX=mdx) + self.mdx = nv_view.MDX + mdx_view = MDXView(cube_name=self.cube_name, view_name=self.mdx_view_name, MDX=self.mdx) # create mdx view on Server self.tm1.views.create(view=mdx_view, private=private) + mdx_view = MDXView( + cube_name=self.cube_name, + view_name=self.mdx_view_with_dynamic_properties_name, + MDX=self.mdx, + dynamic_properties=self.build_dynamic_properties(), + ) + self.tm1.views.create(view=mdx_view, private=private) + def test_view_exists(self): for private in (True, False): self.assertTrue( @@ -239,34 +272,6 @@ def test_update_mdxview(self): def test_create_mdx_view_with_dynamic_properties(self): for private in (True, False): - mdx = ( - "SELECT " - "NON EMPTY{{[{dim0}].Members}} ON 0, " - "NON EMPTY{{[{dim1}].Members}} ON 1 " - "FROM [{cube}] " - "WHERE ([{dim2}].[Element 1])".format( - dim0=self.dimension_names[0], - dim1=self.dimension_names[1], - cube=self.cube_name, - dim2=self.dimension_names[2], - ) - ) - dynamic_properties = { - "Meta": { - "ExpandAboves": { - f"[{self.dimension_names[0]}].[{self.dimension_names[0]}]": False, - f"[{self.dimension_names[1]}].[{self.dimension_names[1]}]": False, - f"[{self.dimension_names[2]}].[{self.dimension_names[2]}]": False, - } - } - } - mdx_view = MDXView( - cube_name=self.cube_name, - view_name=self.mdx_view_with_dynamic_properties_name, - MDX=mdx, - dynamic_properties=dynamic_properties, - ) - self.tm1.views.create(view=mdx_view, private=private) self.assertTrue( self.tm1.views.exists( cube_name=self.cube_name, view_name=self.mdx_view_with_dynamic_properties_name, private=private @@ -275,25 +280,17 @@ def test_create_mdx_view_with_dynamic_properties(self): retrieved = self.tm1.views.get_mdx_view( cube_name=self.cube_name, view_name=self.mdx_view_with_dynamic_properties_name, private=private ) - self.assertEqual(dynamic_properties, retrieved.dynamic_properties) + self.assertEqual(self.build_dynamic_properties(), retrieved.dynamic_properties) self.assertIsInstance(retrieved, MDXView) self.assertEqual(self.mdx_view_with_dynamic_properties_name, retrieved.name) - self.assertEqual(mdx, retrieved.MDX) + self.assertEqual(self.mdx, retrieved.MDX) def test_update_mdx_view_with_dynamic_properties(self): for private in (True, False): mdx_view = self.tm1.views.get_mdx_view( cube_name=self.cube_name, view_name=self.mdx_view_with_dynamic_properties_name, private=private ) - dynamic_properties = { - "Meta": { - "ExpandAboves": { - f"[{self.dimension_names[0]}].[{self.dimension_names[0]}]": True, - f"[{self.dimension_names[1]}].[{self.dimension_names[1]}]": True, - f"[{self.dimension_names[2]}].[{self.dimension_names[2]}]": True, - } - } - } + dynamic_properties = self.build_dynamic_properties(expanded=True) mdx_view.dynamic_properties = dynamic_properties # update should not raise self.tm1.views.update(view=mdx_view, private=private) @@ -405,10 +402,69 @@ def test_is_native_view(self): self.tm1.views.is_mdx_view(cube_name=self.cube_name, view_name=self.mdx_view_name, private=private) ) + def test_create_native_view_with_dynamic_properties(self): + for private in (True, False): + dynamic_properties = { + "Meta": { + "ExpandAboves": { + f"[{self.dimension_names[0]}].[{self.dimension_names[0]}]": False, + f"[{self.dimension_names[1]}].[{self.dimension_names[1]}]": False, + f"[{self.dimension_names[2]}].[{self.dimension_names[2]}]": False, + } + } + } + native_view = NativeView( + cube_name=self.cube_name, + view_name=self.native_view_with_dynamic_properties_name, + suppress_empty_columns=True, + suppress_empty_rows=True, + dynamic_properties=dynamic_properties, + ) + subset = AnonymousSubset( + dimension_name=self.dimension_names[0], + hierarchy_name=self.dimension_names[0], + elements=["Element 1", "Element 2", "Element 3"], + ) + native_view.add_column(dimension_name=self.dimension_names[0], subset=subset) + subset = AnonymousSubset( + dimension_name=self.dimension_names[1], + hierarchy_name=self.dimension_names[1], + elements=["Element 1"], + ) + native_view.add_title(dimension_name=self.dimension_names[1], subset=subset, selection="Element 1") + subset = AnonymousSubset( + dimension_name=self.dimension_names[2], + hierarchy_name=self.dimension_names[2], + elements=["Element 1", "Element 2"], + ) + native_view.add_row(dimension_name=self.dimension_names[2], subset=subset) + self.tm1.views.create(view=native_view, private=private) + self.assertTrue( + self.tm1.views.exists( + cube_name=self.cube_name, + view_name=self.native_view_with_dynamic_properties_name, + private=private, + ) + ) + retrieved = self.tm1.views.get_native_view( + cube_name=self.cube_name, + view_name=self.native_view_with_dynamic_properties_name, + private=private, + ) + self.assertEqual(dynamic_properties, retrieved.dynamic_properties) + self.assertIsInstance(retrieved, NativeView) + self.assertEqual(self.native_view_with_dynamic_properties_name, retrieved.name) + self.tm1.views.delete( + cube_name=self.cube_name, + view_name=self.native_view_with_dynamic_properties_name, + private=private, + ) + def tearDown(self): for private in (True, False): - self.tm1.views.delete(cube_name=self.cube_name, view_name=self.native_view_name, private=private) - self.tm1.views.delete(cube_name=self.cube_name, view_name=self.mdx_view_name, private=private) + for view_name in self.all_view_names: + if self.tm1.views.exists(cube_name=self.cube_name, view_name=view_name, private=private): + self.tm1.views.delete(cube_name=self.cube_name, view_name=view_name, private=private) @classmethod def tearDownClass(cls):