English Deutsch

 

Analysis Services und das nachträgliche Erlauben von NULL-Werten in den Quelldaten – oder: Wie werd ich bloß die 0en los?

Ich hatte vor kurzem eine interessante Begegnung mit einem SSAS Cube (2008), über die ich an dieser Stelle gerne berichten möchte.

Dabei ging es um einen Cube, bei dem für ein bestimmtes Measure die Leerzeilenunterdrückung nicht so richtig funktionierte. Wenn man dieses Measure, das eigentlich eher selten gefüllt war, abfragte, dann sah das in etwa so aus:

 

clip_image002[1]

 

Wie in diesem aus der Adventure Works stammenden Beispiel (wir sehen hier den Reseller Sales Discount Amount nach Reseller Telefonnummer) wurden die leeren Zellen mit einer 0 angezeigt, was ja bekanntlich nicht leer ist und somit auch nicht unterdrückt werden kann. Bei kurzem Blick in die Quelle fand ich in etwa folgendes Bild:

 

clip_image004[1]

 

War also alles korrekt, was die Analysis Services so angezeigt haben. Der verwunderliche Teil der Geschichte beginnt aber auch erst jetzt.

 

Da es für Measures, bei denen mich die 0en gar nicht interessieren, nur bedingt Sinn macht, diese in der Datenbank abzulegen, habe ich also die Definition der Tabelle so geändert, dass die betroffene Spalte ab sofort NULL-Werte erlaubt. Danach wurden dann noch mit einem kleinen Statement wie unten die 0en eliminiert.

 

UPDATE dbo.FactResellerSales

SET DiscountAmount = NULL

WHERE DiscountAmount = 0

 

Wie fast zu erwarten ist, reicht das aber noch nicht aus, denn bis die Zahlen dann wirklich im Cube landen, haben wir ja noch ein paar Abstraktionsebenen vor uns. In diesem Fall war die nächste zu überwindende Hürde die View, die der Cube für den Datenzugriff benutzt:

 

clip_image006[1]

 

Dort war die Spalte nämlich weiterhin als NOT NULL definiert (im Bild ganz unten). Also erst mal rasch die Metadaten der View aktualisiert:

 

SP_REFRESHVIEW N’dbo.v_FactResellerSales’;

 

Prompt stimmt die View mit der Realität überein. Aber selbstverständlich reicht auch das noch nicht, damit die 0en aus meinem Cube verschwinden. Es gibt da ja auch noch die Data Source View in den Analysis Services, die Metadaten speichert und die ich deswegen vorsorglich aktualisierte. Das Visual Studio meldete mir aber leider nach dem Klick auf den Refresh-Knopf, dass keine Änderungen gefunden wurden. Und wenn man sich dann die Eigenschaften der Spalte in der DSV anschaut:

 

clip_image008[1]

 

Dann gibt es eine Eigenschaft AllowNull, die leider immer noch auf False stand. Und

natürlich sind die 0en nach erneuter Aufbereitung auch immer noch im Cube.

Da ich bei meiner View als Datenquelle bleiben wollte habe ich also kurzfristig „Tabelle ersetzen durch neue benannte Abfrage“ gewählt und danach dann wieder die ursprüngliche View über „Tabelle ersetzen durch andere Tabelle“ übernommen. Und endlich: AllowNull steht auf true. Das Ganze also schnell auf dem Server bereitgestellt, aufbereitet und … NICHTS! Immer noch grinsen mich die 0en förmlich an.

Hm, ist vielleicht beim Measure selbst noch was falsch eingestellt?

 

clip_image010[1]

 

Eigentlich sieht alles gut aus. NullProcessing steht auf Automatic, was der Default ist. Ich persönlich musste das zwar bisher auch noch nie ändern, aber irgendwann ist ja immer das erste Mal:

 

clip_image012[1]

 

Nach dem Ändern des NullProcessing auf Preserve verwunderte dann beim Bereitstellen, dass die automatische Aufbereitung keinen Anlass sah, die betroffene Measure Group neu mit Daten zu befüllen. Wen wundert’s dann noch, dass die 0en auch immer noch da waren.

 

Die nun folgende manuelle Aufbereitung war wohl die leichteste Übung, und endlich:

 

clip_image014[1]

 

Ich bin die 0en los!

Range-Lookups mit den Integration Services – Teil III

In den vorangegangenen beiden Teilen dieses Artikels (Teil I, Teil II) haben wir uns mit den Möglichkeiten des Range-Lookups beschäftigt, die ohne weitere Programmierung oder zusätzliche Komponenten mit reinen SSIS-Boardmitteln realisiert werden können. Nun wollen wir uns mit einer dritten Variante beschäftigen, um in unserem Beispielszenario die Kunden nach Ihrem Einkommen in die bereits bekannten Einkommensgruppen einzuteilen:

Lösung mittels Script Task – Wunderwaffe “Binary Search”

Wenn einen die mitgelieferten Standardkomponenten der Integration Services mal wieder nicht so richtig ans Ziel bringen, dann gibt es ja zum Glück immer noch den Script Task, den wir auch dazu nutzen können, um einen eigenen Lookup zu realisieren. Und genau das werden wir jetzt tun. Script Transformation in unseren Datenfluss gezogen und schon kann es mit der Definition der Eingabespalten beginnen. In unserem Fall reicht das YearlyIncome aus:

Danach müssen wir dann noch die zugehörige Ausgabespalte definieren, die dann später unsere Gruppen-ID aufnehmen soll:

Da ich die möglichen Gruppen direkt im Script-Task aus der Datenbank lesen möchte (es gibt auch andere, durchaus sauberere Wege), benötige ich auch noch einen Verbindungsmanager (der Einfachheit halber mit dem .NET Provider für SQL Server):

Nun kann ich mir noch die Programmiersprache auswählen (ich nehm mal C#) und endlich mit der eigentlichen Arbeit beginnen.

Als erstes benötige ich einen gefüllten Cache, der die möglichen Ausprägungen meiner Gehaltsgruppen aufnehmen kann. Diesen definiere ich als globale generische Liste einer eigens dafür gedachten Klasse Namens “NumericRangeItem”:

Im PreExecute übernehme ich dann den Freigegebenen Verbindungsmanager und fülle einmalig den besagten Cache:

Im FillCache() werden dann die oben gezeigten Zeilen aus der Datenbank gelesen und für jede Zeile ein neues NumericRangeItem in den Cache übernommen. Das NumericRangeItem ist dabei im wesentlichen ein Key-Value-Pair, wobei IncomeFrom als Key und die zugehörige IncomeGroupID als Value übernommen wird :

Besonderheit ist, dass es die Schnittstelle IComparable implementiert. Dazu muss die Klasse also eine Methode namens CompareTo enthalten, die aber auch ziemlich simpel ist.

Damit wird lediglich festgelegt, dass beim Vergleich zweier NumericRangeItems die Schlüssel (also die Einkommensgrenze) miteinander verglichen werden.

Ergebnis ist, dass ich nach dem PreExecute alle Einkommensgrenzen als generische Liste vergleichbarer Elemente im Speicher habe. Belohnt werde ich für diesen Aufwand dann damit, dass eine solche Liste eine Methode namens BinarySearch() zur Verfügung stellt, die wir dann für den eigentlichen Lookup nutzen können:

Für jede Zeile die ein Einkommen enthält wird die Funktion GetID aufgerufen, die einen numerischen Wert übergeben bekommt und das dazu passende NumericRangeItem zurückgibt. Der zu suchende Wert wird dabei selbst in einem NumericRangeItem verpackt, damit der Vergleich funktioniert. Und dann kann BinarySearch() die eigentliche Arbeit übernehmen. Dabei wird immer in die Mitte der Wertmenge geschaut und überprüft ob der dort vorhandene Wert größer oder kleiner als der Vergleichswert ist und dann mit der jeweils passenden Hälfte weitergearbeitet. Dadurch kann man mit BinarySerach auch in großen Datenmengen sehr schnell suchen (im schlimmsten Fall werden log2(N) + 1 Iterationen benötigt => zum Durchsuchen von 1 Mio Datensätzen werden höchstens 20 Versuche gebraucht). Was das Ganze für unser Szenario aber erst nutzbar macht, ist, dass auch für Werte, die nicht gefunden werden können ein Ergebnis geliefert wird.

Mathematisch ist dieser negative Rückgabewert das Komplement des nächstgrößeren Index. Da wir über die Untergrenzen suchen (IncomeFrom) erhalten wir also mit if (iScore < 0) iScore = (short)(~iScore – 1); genau das Ergebnis, dass wir für unseren RangeLookup brauchen.

Das Ergebnis sieht dann wie zu hoffen war folgendermaßen aus:

Dieses Bild haben wir so ähnlich bereits im Teil I gesehen, nur dass das Ergebnis diesmal in nicht mal einem Sechstel der Zeit berechnet war. Bei größeren Datenmengen verstärkt sich dieser Effekt sogar noch weiter. In unserem kleinen Beispiel benötigt der Script Task noch fast die Hälfte für das PreExecute, in dem ja auch der Cache gefüllt werden muss. Wenn wir aber nicht mehr 18k sondern ein paar Millionen Datensätze durch die Pipeline schicken, fällt dieser Overhead nicht mehr ins Gewicht.

Mit der Wunderwaffe BinarySearch brauchen wir also in Zukunft keine Angst mehr vor Range-Lookups bei großen Datenmengen zu haben.

Range-Lookups mit den Integration Services – Teil II

Wie im Teil I versprochen wollen wir uns nun mit einer weiteren Möglichkeit für Range-Lookups mit den Integration Services auseinandersetzen.

Dazu zur Erinnerung noch einmal unsere Quelltabelle, aus der wir die zugehörigen IDs ermitteln wollen:

Lösung mittels Lookup – Mit Caching

Wie wir festgestellt haben, verliert der Lookup bei ausgeschaltetem Caching enorm an Geschwindigkeit. Alternativ könnte man also versuchen, den Range-Lookup mit aktiviertem Caching zu realisieren. Da der Lookup dann aber nur genaue Übereinstimmungen als Treffer wertet, muss für jeden möglichen Wert eine Zeile in der Lookup Tabelle existieren. Angenommen die Einkommen sind im beschriebenen Fall als volle Eurobeträge gespeichert, ließe sich das mit folgender rekursiven Common Table Expression (CTE) realisieren:

WITH IncomeGroups(IncomeGroupID,Income, IncomeTo)

AS

(

SELECT    [IncomeGroupID],[IncomeFrom] as Income, [IncomeTo]

FROM    dbo._MATCH_IncomeGroup

WHERE    IncomeGroupID < 7

UNION
ALL

SELECT [IncomeGroupID], Income + 1, [IncomeTo]

FROM IncomeGroups

WHERE Income + 1 <= IncomeTo

)

SELECT Income, IncomeGroupID

FROM IncomeGroups

ORDER
BY 1,2

OPTION (MAXRECURSION 0)

Dieses Statement liefert im Management das folgende Ergebnis:

Dies könnte als Quelle für den Lookup Task verwendet werden der dann wie jeder andere gewöhnliche Lookup konfiguriert werden kann.

Anschließend müsste dann die oberste Grenze, die in der Quelle bewusst ausgelassen wurde (denn 999.999.999 Datensätze wollte ich nun wirklich nicht erzeugen) per Abgeleitete Spalte (derived column) gesetzt werden.

Für Situationen in denen sehr viele Datensätze verarbeitet werden müssen, könnte dies eine gute Alternative zu der Variante ohne Caching sein. Es wird aber auch schnell klar, dass auch dieses Verfahren seine Grenzen hat. Für eine Aufteilung in Altersklassen von Kunden, für die es nur sehr wenige Ausprägungen in den Quelldaten gibt, ist dies wahrscheinlich die beste Lösung. Wenn es aber darum geht Firmenumsätze, die in die Milliarden gehen, in Gruppen einzuteilen, wird sowohl die Abfragezeit der Rekursion, als auch der benötigte Speicherplatz den Rahmen sprengen. Spätestens aber wenn die Grenzen nicht mehr mit ganzen Zahlen definierbar sind, ist dann wirklich Schluss.

Um auch für diese Situationen gerüstet zu sein, wird sich der nächste und vermutlich letzte Teil dieses Artikels mit der dritten Alternative, dem Range-Lookup mittels Script Task auseinandersetzen.

Excel 2010 – Comeback des OLAP-Writebacks

Hallo zusammen,

ich habe in einer Feature-Liste für das neue Excel 2010 etwas gelesen, was ich unbedingt mal ausprobieren musste: Das allseits gefürchtete/geliebte Writeback für OLAP-Cubes kommt zurück.

Meine Ergebnisse möchte ich Euch natürlich nicht vorenthalten. Ich habe mir in einem 2008er Cube (Adventure Works 2008) eine kleine, einfache Partition mit Planzahlen gebaut und habe dort das Writeback im Modus ROLAP aktiviert (die AS können das mit dem Zurückschreiben übrigens durchweg seit mind. Version 2000 und mit dem Excel Add-in for SQL Server Analysis Services konnte man das sogar schon mal benutzen).

Dann habe ich mich mit Excel 2010 auf diesen Cube verbunden und die “What-If-Analyis” aktiviert.

Folgende Settings habe ich genommen.

Dann hab ich mein Measure mit zwei Dimensionen in die Pivot-Tabelle gepackt…

… und den Wert für Europa erhöht.

Nach dem Neuberechnen der Pivot-Tabelle haben sich alle zugehörigen Werte erhöht.

Allerdings ist das ganze bis dahin nur eine Art Simulation. Erst wenn man es explizit sagt, werden die Änderungen in den Cube übernommen.

Dort landen sie dann in der vorher definierten Tabelle.

Nett, oder? Und die aus alten Zeiten bekannten Performanceprobleme beim Writeback auf größere Datenmengen sollen seit SQL Server 2008 auch der Vergangenheit angehören, da nicht mehr nur ROLAP, sondern auch MOLAP unterstützt wird. Das kann man aber auf einer Adventure Works schlecht überprüfen.

Range-Lookups mit den Integration Services – Teil I

In den von uns gebauten Integration Services Paketen ist der Lookup der wohl am meisten genutzte Task und im Grunde macht er seine Arbeit auch meistens gut und schnell. Spätestens wenn man während des ETLs jedoch fortlaufende Werte wie z.B. das Einkommen eines Kunden in Gruppen übersetzen will, stößt man mit dem Lookup an seine Grenzen. Diese Artikel-Reihe beschreibt zwei Workarounds, mit denen sich dieses Problem mit dem Lookup trotzdem bewältigen lässt und schlägt zum Schluss eine wesentlich performantere Lösung mittels Script Task vor.

Zur besseren Nachvollziehbarkeit bezieht sich das folgende Szenario auf die Microsoft Demo-Datenbank AdventureWorksDW2008, die unter http://msftdbprodsamples.codeplex.com/ heruntergeladen werden kann. Alle Screenshots basieren auf dem SQL Server 2008, die beschriebenen Vorgehensweisen lassen sich aber auch mit dem SQL Server 2005 realisieren.

Ausgangssituation

In den Quelldaten für die Kunden gibt es eine Spalte, die das jährliche Einkommen der jeweiligen Kunden enthält (wie ein Fahrradhändler an solche Informationen kommt ist nicht nur Ihnen ein Rätsel). Um nach dieser Information später im Cube bzw. den Berichten besser auswerten zu können, sollen mehrere Einkommensgruppen entstehen, in die die einzelnen Kunden einsortiert werden müssen. Die dafür nötige Zuordnungstabelle hat folgenden Aufbau:

Um den “normalen” ETL-Aufwand für dieses Beispiel möglichst gering zu halten, nutzen wir als Quelldaten für die Kunden bereits die Kundendimension des DWHs, in der alle relevanten Kundeninformationen bereits zusammen vorliegen. Diese sollen nun um die IncomeGroupID aus unserer Zuordnungstabelle ergänzt werden.

Lösung mittels Lookup – Ohne Caching

Die hohe Geschwindigkeit des Lookups resultiert vor allem daraus, dass alle dem Lookup zu Grunde liegenden Daten bereits vor der eigentlichen Ausführung in den Hauptspeicher geladen werden und das Nachschlagen der Einzelwerte dann direkt im RAM erfolgen kann. In diesem Modus kann man aber leider nur exakt gleiche Werte wiederfinden. Eine Zuordnung zu einem Bereich, wie er in diesem Fall nötig ist, ist mit eingeschaltetem Caching nicht möglich. Da aber der Weg zum Ziel auch bei ausgeschaltetem Caching alles andere als intuitiv ist, wollen wir uns das doch mal genauer anschauen.

Die Quelldaten werden 1:1 aus der Tabelle DimCustomer gelesen, mit dem Lookup um die zusätzliche Spalte ergänzt und ins Ziel (hier ins Nirvana) geschrieben. Der Lookup ist dabei folgendermaßen konfiguriert:

Der Cache muss also wie gesagt deaktiviert werden.

Als Quelle werden die untere und obere Grenze und die daraus resultierende GruppenID benötigt.

Die Verknüpfung wird erst einmal so eingerichtet, als ob ein Standard-Lookup über eine Spalte eingerichtet wird, damit wir die Ergebnisspalte auswählen können.

Erst unter dem Punkt “Erweitert” kommt dann die eigentliche Besonderheit ins Spiel. Durch das deaktivieren des Caches, können wir eine benutzerdefinierte Abfrage angeben, die für JEDE! Zeile des Datenflusses ausgeführt wird. Diese kann dann wie gezeigt parametrisiert werden. Resultat ist also, dass nicht mehr nach einem speziellen Wert gesucht wird, sondern der Ausdruck “YearlyIncome BETWEEN IncFrom AND IncTo” ausgewertet und die gewünschte GruppenID zurückgegeben wird.

Um das Ergebnis begutachten zu können, hängen wir hinter den Lookup einen Datenviewer mit den wichtigsten Spalten und lassen das ganze laufen.

Wie man sieht erhalten wir das gewünschte Ergebnis. Allerdings deutet sich bereits bei dieser sehr kleinen Datenmenge an, dass die Geschwindigkeit nicht gerade Rekordverdächtig ist. Für kleine bis mittlere Datenmengen ist diese Lösung aber durchaus brauchbar.

Demnächst werden wir uns dann noch mit zwei weiteren Alternativen beschäftigen, die je nach Szenario eine bessere Performance erreichen können.

Korrekte MDX-Berechnungen mit den Analysis Services trotz Multiselect

Seit mehreren Jahren wird immer wieder über die Problematik von berechneten Elementen in den Analysis Services in Verbindung mit Multiselects diskutiert.

 

So schrieb Mosha bereits 2005 in Writing multiselect friendly MDX calculations über die Probleme, die auftreten wenn eine Berechnung ein CURRENTMEMBER benötigt und Sets in der WHERE Clause verwendet werden.

 

Das Thema ist aber leider immer noch aktuell und wurde durch Excel 2007 und dessen Vorliebe für Subselects beim Auswählen mehrerer Elemente der gleichen Hierarchie eher noch verschlimmert. Denn dann geben die Analysis Services unter Umständen keinen Fehler mehr zurück, sondern zeigen einfach falsche Werte an.

 

Da die Hoffnung „Mit Katmai wird alles besser“ für diese Problematik auch nicht zutrifft, wird im Folgenden noch mal auf die Problematik eingegangen und anschließend mit Hilfe von SCOPE eine mögliche Lösung erklärt. Die Beispiele basieren wie immer auf dem Adventure Works Cube, den es bei Codeplex zum Download gibt.

 

Das Problem

Um das eigentliche Problem zu verstehen, benötigen wir zwei berechnete Elemente. Zum einen werden wir uns „Ratio to Parent Product“ anschauen, das bereits in der Adventure Works enthalten ist und folgende Definition hat:

 

Create Member CurrentCube.[Measures].[Ratio to Parent Product]

 

 As Case

 

        When [Product].[Product Model Categories].CurrentMember.Level.Ordinal

             = 0

        Then 1

 

        Else [Measures].[Sales Amount]

             /

             ( [Product].[Product Model Categories].CurrentMember.Parent,

               [Measures].[Sales Amount] )

 

    End,

Format_String = "Percent" ;

Zusätzlich definieren wir uns ein einfaches eigenes Measure, dass die Anzahl der Produkte für die jeweilige Auswahl anzeigt:

 

Create MEMBER CurrentCube.[Measures].[Number of Products]

AS

    COUNT(EXISTING([Product].[Product].[Product].MEMBERS));

EXISTING sorgt dabei dafür, dass nicht immer die gleiche Anzahl zurückgegeben wird, sondern das Measure für die jeweils ausgewählten Koordinaten des Cubes neu berechnet wird.

 

Nun können wir uns das ganze in Excel ansehen. Wenn wir die beiden berechneten Measures und zusätzlich noch den Sales Amount auswählen und die „Product Categories“ auf den Zeilen ausgeben, dann ergibt sich folgendes Bild:

 

clip_image002

 

Wie zu erwarten war, werden sowohl Number of Products, als auch die Ratio to Parent richtig für die einzelnen Produktkategorien angezeigt. Dass die Summe der Einzelwerte der „Number of Products“ nicht dem Gesamtergebnis entspricht, liegt übrigens daran, dass 209 Produkte dem, unglücklicherweise unsichtbaren, UnknownMember zugeschlagen wurden.

 

Wenn wir nun die Produktkategorien von den Zeilen in den Filter verschieben und dort mehrere Elemente auswählen,

 

clip_image004

 

dann nutzt Excel im Hintergrund ein SUBSELECT, um diese Einschränkung abzubilden. Das Ergebnis sieht dann folgendermaßen aus:

 

clip_image006

 

Für den Sales Amount wird korrekt die Summe der Kategorien „Accessoires“ und „Bikes“ angezeigt, was beweist, dass Excel mit seinem MDX nicht völlig daneben liegen kann. Trotzdem stimmen die Zahlen der anderen beiden Measures nicht. Es sieht aus, als wäre gar keine Einschränkung gemacht worden. Würde man sich ein Measure definieren, das [Product].[Product Model Categories].CurrentMember.Name ausgibt, dann könnte man sehen, dass genau das geschehen ist. Innerhalb der MDX-Scripts, gibt es nur eine gewählte Produktkategorie, nämlich „[All]“. Unsere berechneten Elemente bekommen von der Tatsache, dass sie in einem beschränkten Subcube laufen, nichts mit.

 

Die Lösung?

Die Grundidee, um dieses Problem zu umgehen, ist echte Measures anzulegen und diese dann per SCOPE so zu überschreiben, dass die eigentlichen Werte dann wieder per MDX ermittelt werden.

 

Beginnen wir also mit dem Anlegen eines „echten“ Measures für die Anzahl der Produkte. Da es diesen Wert auf Faktenebene nicht gibt, müssen wir uns also erst mal eine Spalte erzeugen, die wir später als Quelle für unser neues Measure nutzen können. Wir erzeugen uns also in der Data Source View für die FactSalesSummary eine leere Dummy-Spalte.

 

clip_image008

 

Dazu überarbeiten wir die Abfrage, die hinter der Faktentabelle liegt so, dass in beiden Teilen des UNIONS die neue Spalte „Dummy“ auftaucht.

 

SELECT    

     

      , NULL AS [Dummy]

FROM FactResellerSales

UNION

SELECT    

     

      , NULL AS [Dummy]

FROM FactInternetSales

 

Nun können wir diese Spalte im Cube verwenden und erzeugen uns auf dessen Basis ein neues Measure Namens „Scoped Number of Products“.

 

clip_image010

 

Dabei ist zu beachten, dass die Aggregat-Funktion, die bei Usage ausgewählt wird, später auch wirklich Anwendung findet. Während bei berechneten Elementen auf jeder Ebene im Cube die Berechnung stattfindet, wird beim SCOPING „nur“ für den explizit überschriebenen Subcube das MDX verwendet. Für alle nicht überschriebenen Bereiche wird normal aggregiert, wobei allerdings die „neuen“ Werte herangezogen werden.

 

Wir wählen also bei Usage die Summe aus und haben unser neues Measure. Nach dem Bereitstellen und Aufbereiten des überarbeiteten Cubes können wir das auch schon verwenden, erhalten aber selbstverständlich nur leere Zellen. Die eigentliche Berechnung müssen wir nun noch unter „Calculations“ definieren:

 

SCOPE ([Measures].[Scoped Number of Products], [Product].[Product].[Product].MEMBERS );

    THIS = 1;

    SCOPE ([Product].[Product].[All Products].UnknownMember);

       THIS = 0;

    END SCOPE;

END SCOPE;

 

Für alle Elemente des Levels “Product” in der Hierarchie “Product” der Dimension “Product” wird damit der Wert des Measures „Scoped Number of Products“ auf 1 gesetzt. Alle anderen Ebenen und Hierarchien, wie z. B. der All-Knoten, werden dabei ignoriert, da wir dafür ja die bereits definierte Summen-Funktion nutzen wollen.

 

Um eine Vergleichbarkeit mit dem alten berechneten Element herzustellen, verhindert das zweite SCOPE lediglich, dass das unsichtbare UnknownMember der Produkt-Hierarchie mitgezählt wird.

 

Um zu überprüfen, ob die Zahlen passen, nehmen wir das neue Measure nun in unsere erste Abfrage von oben mit auf:

 

clip_image012

 

Soweit, so gut. Spannender wird es jetzt aber, wenn wir die Kategorien wieder von den Zeilen in den Filter verschieben und nur die ersten beiden Elemente auswählen – Excel also wieder sein beliebtes SUBSELECT ausführt:

 

clip_image014

 

Wie erhofft ergibt 35 + 125 nun nicht mehr 606, sondern Excel gibt die korrekte Summe 160 zurück. Dadurch, dass wir das Measure auf der untersten Ebene der Produktdimension überschrieben haben, nutzen die Analysis Services nicht mehr bestehende Aggregate, sondern müssen alle betroffenen Produkte zur Laufzeit berechnen. Die anschließend stattfindende Aggregation der Werte wird dann nur für den betroffenen Subcube, also unter Berücksichtigung des Subselects, durchgeführt.

 

Um zu zeigen, dass dies nicht nur für simples Zählen funktioniert, erzeugen wir uns ein weiteres Measure auf Basis unserer Dummy-Spalte, nennen es „Scoped Ratio to Parent Product“ und erweitern unser MDX-Script um folgenden Ausdruck:

 

SCOPE ([Measures].[Scoped Ratio to Parent Product], [Product].[Product Model Categories].MEMBERS);

    SCOPE(DESCENDANTS([Product].[Product Model Categories].[All Products], , AFTER));

        THIS = [Measures].[Sales Amount]

             / ( [Product].[Product Model Categories].CurrentMember.Parent,

               [Measures].[Sales Amount] );

    END SCOPE;

    Format_String(THIS) = "Percent" ;

END SCOPE;

 

Dabei sorgt das DESCENDANTS(…) dafür, dass alle Elemente der Hierarchie “Product Model Categories” außer dem All-Knoten überschrieben werden. Das Ergebnis in Excel sieht dann folgendermaßen aus:

 

clip_image016

 

Auch hier sorgt das Überschreiben auf unterster Ebene dafür, dass die Berechnung richtig funktioniert. In dem Augenblick, in dem die einzelnen Knoten der ausgewerteten Hierarchie berechnet werden, gibt es eben immer ein CurrentMember, egal ob nun ein oder mehrere Knoten ausgewählt sind.

 

Einschränkungen

Zugegeben, habe ich mir das ganze hier etwas leicht gemacht. Die beiden Berechnungen einfach zu summieren, macht nämlich eigentlich keinen Sinn. Sowohl die Anzahl der Produkte, als auch die Prozente sind sogenannte semi-additive Measures, die eben nicht einfach summiert werden dürfen. Für beide fehlt also mindestens eine Sonderbehandlung für die Zeitdimension, die durch Nutzung anderer Aggregatfunktionen oder erweiterter SCOPE-Statements erreicht werden könnte, die den Rahmen dieses Beitrags sprengen würden.

 

Außerdem muss ich davor warnen jetzt ab sofort alle berechneten Elemente durch solche SCOPEs abzulösen, denn das ganze hat natürlich einen Haken. Der Grund, warum das Ganze funktioniert, nämlich das Berechnen auf unteren Ebenen, hat gleichzeitig negative Auswirkungen auf die Performance. Es können eben nicht mehr vorberechnete Werte aus den Aggregationen oder dem Cache übernommen werden. Bei jeder Abfrage, die sonst nur wenige Zellen zur Berechnung hätte nutzen müssen, muss jetzt unter Umständen ein Vielfaches an Daten berücksichtigt werden, um das gewünschte Ergebnis zu erhalten.

 

Nichts desto trotz zeigt das Ganze, dass es möglich ist, Berechnungen so zu definieren, dass sie Multiselect-fähig sind, obwohl sie Funktionen wie EXISTING oder CurrentMember benötigen. Wer mit relativ wenig Daten arbeitet, oder wem es nicht gelingt seinen Nutzern das Multiselect in Excel auszureden, der kommt so vielleicht zu einer Lösung.

Dimensionsdatensicherheit als Fehlerquelle für "unsaubere" Sets

Hallo liebe Reportbauer, MDXler und alle die es werden wollen,

ich bin vor kurzem bei einem Kunden über ein Phänomen gestolpert, dass ich Euch nicht vorenthalten will.

 

Wie immer wieder von mir gepredigt wird, arbeiten wir in der Regel zur Ausklammerung bestimmter Dimensionsknoten mit so genannten „Negativ-Listen“. Das bedeutet, wenn wir aus der Dimension Buchstaben die Knoten A, B und E sehen wollen sagen wir nicht {A, B, E} sondern {Buchstaben.ALLE – {C, D}}. Dadurch würden neue Knoten in der Dimension (z.B. der neu eingeführte Buchstabe F ;) ) nicht per Default unterdrückt, sondern wären erst mal sichtbar. In den meisten echten Anwendungsfällen ist diese Negativ-Liste oft auch deutlich kürzer als die Positiv-Liste. Aber genug über den Sinn und Unsinn von Negativ-Listen, das Statement

 

SELECT

{}

ON COLUMNS,

{[Product].[Category].[Accessories], [Product].[Category].[Bikes], [Product].[Category].[Clothing]}

ON ROWS

FROM

[Adventure Works]

 

liefert das gleiche Ergebnis zurück, wie

 

SELECT

{}

ON COLUMNS,

{[Product].[Category].[Category].

MEMBERS – [Product].[Category].[Components]} ON ROWS

FROM

[Adventure Works]

 

Nun hat sich innerhalb des besagten Kundenprojektes heimlich durchgesetzt, dass die abgekürzte Schreibweise verwendet wird, also {– {C, D}} statt { Buchstaben.ALLE – {C, D}}. Also auch die folgende dritte Variante hat das gleiche Ergebnis wie die oberen beiden.

 

SELECT

{}

ON COLUMNS,

{-{[Product].[Category].[Components]}}

ON ROWS

FROM

[Adventure Works]

 

Dies hat eigentlich auch super funktioniert, ist an Kürze kaum noch zu übertreffen UND SOLLTE TROTZDEM NIE WIEDER GETAN WERDEN ;-)

 

Implizit erkennt der AS bei dieser Schreibweise, welche Elemente auf der linken Seite dieses Komplements gehören, nämlich Buchstaben.ALLE. Sobald aber eine Dimensionssicherheit ins Spiel kommt wird’s gefährlich. AS sucht die Knoten dann nur noch in dem Bereich in dem der Nutzer berechtigt ist. Wenn er den Knoten da nicht findet, weiß er nicht, ob er nicht existiert, oder ob er nicht berechtigt ist und ersetzt ihn durch die leere Menge ({}).

 

Aus { Buchstaben.ALLE – {C, D}} würde also intern { Buchstaben.ALLE – { }}. Nicht schlimm, funktioniert super. Ihr könnt Euch aber denken, dass das nicht mehr funktioniert, wenn aus {– {C, D}} durch dieses Verhalten {-{}} wird. Und das Böse daran ist, dass das erst auftritt wenn der erste eingeschränkte Nutzer darauf zugreift. Für Euch als Entwickler sieht es aus als wäre alles fein, während der arme Benutzer mit der Meldung "Die Menge muss eine einzige Hierarchie aufweisen, um mit dem Komplementoperator verwendet zu werden." bzw. "The set must have a single hierarchy to be used with the complement operator." begrüßt wird.

 

Also kurz:

Niemals {– {C, D}}

Immer { Buchstaben.ALLE – {C, D}}  oder aber EXCEPT(Buchstaben.ALLE, {C, D})

Trendlinie in Reporting Services 2005 dank MDX

Viele Controller kennen und lieben sie aus Excel: die Trendlinie. Mal eben die Umsätze der letzten Quartale als Balkendiagramm betrachtet und mit zwei Klicks den Trend visualisiert.

 

Trendlinie in Excel

 

Um so verwirrter guckt dann dieser Controller, wenn man ihm versucht zu erklären, dass das mit den Reporting Services nicht möglich ist. Zum Glück stimmt das auch nicht ganz.

Mit Hilfe von Dundas Chart für Reporting Services ist dies fast so einfach wie in Excel. Der Haken liegt dann nur noch bei den Lizenzkosten.

 

Aber auch ohne zusätzliche Software sind einfache Trendlinien mit SSRS möglich, denn genaugenommen ist das ja nichts weiter als ein “bisschen” Mathematik.

Grundlage für die oben gezeigte gerade Trendlinie ist die einfache lineare Regression. Und dafür gibt es in MDX Funktionen, die sich nutzen lassen, um diesen Trend zu berechnen. Die Grundlagen dieser MDX Funktionen und der mathematischen Konzepte dahinter hat Mosha bereits vor einigen Jahren in seinem Blog veröffentlicht.

 

Nun aber zurück zum eigentlichen Thema, das Erzeugen der oben gezeigten Grafik in den Reporting Services:

Mit dem Assistenten erstellen wir einen neuen Bericht auf den Adventure Works Cube aus der Adventure Works DW Datenbank. Nach dem Umschalten des Designmodes kommt für das Dataset folgender Quellcode zum Vorschein:

SELECT

NON EMPTY { [Measures].[Sales Amount] } ON COLUMNS,

NON EMPTY { ([Date].[Calendar].[Calendar Quarter].ALLMEMBERS) } ON ROWS

FROM [Adventure Works]

 

Um später die Lesbarkeit des Codes zu erhöhen, erstellen wir uns als erstes ein Set, dass alle Quartale enthält, die wir auch der Achse anzeigen wollen.

WITH

SET

myMonate AS ([Date].[Calendar].[Calendar Quarter].ALLMEMBERS)

 

Nun müssen wir für jede Zeile den Wert ermitteln, der für das jeweilige Quartal auf der Trendlinie liegt. Dazu nutzen wir die MDX-Funktion: LinRegPoint.

 
LinRegPoint(Slice_Expression_x, Set_Expression, Numeric_Expression_y [ ,Numeric_Expression_x ] )

 

Slice_Expression_x: Hier wird ein numerischer Ausdruck erwartet, der als x-Wert genutzt werden soll. Da die Quartale nicht numerisch sind, können wir diese hier nicht direkt benutzen. Wir können jedoch die RANK-Funktion nutzen, um die Position des jeweiligen Quartales auf der X-Achse zu ermitteln. Diesen merken wir uns in einem eigenen Member:

MEMBER

Measures.myX AS Rank([Date].[Calendar].CurrentMember, [Date].[Calendar].CURRENTMEMBER.LEVEL.MEMBERS)

 

Set_Expression: An dieser Stelle wird definiert, über welches Set der Trend-Wert berechnet werden soll. Hier könnte z.B. die letzten 10 Perioden gewählt werden, oder ein Set, das von Berichtsparametern abhängig ist, genutzt werden. In unserem Fall wollen wir einfach alle Quartale zur Berechnung heranziehen und nutzen deshalb das eben erstellte Set myMonate.

 

Numeric_Expression_y: Der gewünschte y-Wert ist einleuchtend: [Measures].[Sales Amount], dafür machen wir das ganze ja schließlich.

 

Numeric_Expression_x: Hier wird die Formel erwartet, die genutzt werden kann, um die x-Werte der anderen Punkte “vorauszusagen”. Wir können hier einfach den gleichen Ausdruck benutzen, wie bei Slice_Expression_x: myX

 

Wenn wir das alles einsetzen und dem Ganzen dann noch einen Namen geben, erhalten wir folgenden Ausdruck:

MEMBER

Measures.[Sales Trend] AS LinRegPoint(Measures.myX, myMonate, [Measures].[Sales Amount], Measures.myX)

 

Unsere Quelle für das Berichts-Dataset sieht nun also folgendermaßen aus:

WITH

SET

myMonate AS ([Date].[Calendar].[Calendar Quarter].ALLMEMBERS)

MEMBER

Measures.myX AS Rank([Date].[Calendar].CurrentMember, [Date].[Calendar].CURRENTMEMBER.LEVEL.MEMBERS)

MEMBER

Measures.[Sales Trend] AS LinRegPoint(Measures.myX, myMonate, [Measures].[Sales Amount], Measures.myX)

SELECT

NON EMPTY { [Measures].[Sales Amount],Measures.[Sales Trend] } ON COLUMNS,

NON EMPTY {myMonate} ON ROWS

FROM [Adventure Works]

 

Und wenn wir das ganze als Grafik anzeigen, dann sieht das in etwa so aus:

 

Trendlinie in den Reporting Services

 

Wenn das unseren Controller nicht glücklich macht ;-)

 

An dieser Stelle sollte noch angemerkt werden, dass die LinRegPoint-Funktion mit Rekursion arbeitet und damit, vor allem bei vielen Zeit-Werten, eine potentielle Performancebremse ist. Wer also beim Darstellen des Trends mit diesem Verfahren an die Grenzen stößt, der sollte dann doch mal Dundas ausprobieren, oder auf die nächste Version des SQL Servers (2008) hoffen.