Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 29 additions & 0 deletions TM1py/Objects/DynamicPropertiesMixin.py
Original file line number Diff line number Diff line change
@@ -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}
43 changes: 18 additions & 25 deletions TM1py/Objects/MDXView.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -97,13 +90,13 @@ 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:
mdx_view_as_dict = collections.OrderedDict()
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)
67 changes: 40 additions & 27 deletions TM1py/Objects/NativeView.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion TM1py/Services/ElementService.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;",
]
)

Expand Down
7 changes: 2 additions & 5 deletions TM1py/Services/ViewService.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
12 changes: 8 additions & 4 deletions Tests/ElementService_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
83 changes: 83 additions & 0 deletions Tests/NativeView_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Loading
Loading