0
votes

I need to create a calculated column. I have a list of items with serial #s, and those items are assigned to someone each month. I need to know (0/1) whether the owner of that item this month is the same as the owner of that item last month. (So I can create a measure to average how many are changing owners month-to-month.)

Basically, I'm trying to achieve the last column:

Month       ItemID  Owner   Same Owner as Prev Mth
2015/01/31  A1      Al  
2015/01/31  A2      Bob 
2015/01/31  A3      Carl    
2015/02/28  A1      Al      1
2015/02/28  A2      Carl    0
2015/02/28  A3      Carl    1
2015/03/31  A1      Bob     0
2015/03/31  A2      Bob     0
2015/03/31  A3      Bob     0
2015/04/30  A1      Bob     1
2015/04/30  A2      Bob     1
2015/04/30  A3      Al      0

I tried a CALCULATE(Max([Owner]), FILTER(tbl, DATEADD([Month],-1,MONTH)=EARLIER([Month]), FILTER(tbl, [ItemID] = EARLIER([ItemID]))

But Max doesn't work on text fields. So I am kind of stumped. I know this shouldn't be that hard...

1

1 Answers

1
votes

Date logic is almost always an issue of modeling rather than clever functions.

You will need a date table with a monotonically incremented integer id for months. I typically refer to this as MonthSequential or MonthIndex depending on the intended audience for the model. This field simply increments by 1 for each month in the date table without wrapping at year boundaries. Thus if the first month in your model is January, 2014, that month will have MonthSequential=1. February, 2014 has MonthSequential=2, and so on to December, 2014 with MonthSequential=12. January, 2015 has MonthSequential=13.

This allows very simple arithmetic to identify any month or range of months an arbitrary amount of time from the current month. Once you have this field in your date dimension (and your Items[Month] field related to your DimDate[Date] field), life gets pretty easy:

SameOwnerPreviousMonth=
IF(
    CALCULATE(
        VALUES(Items[Owner])
        ,FILTER(
            ALLEXCEPT(Items, Items[ItemID])
            ,RELATED(DimDate[MonthSequential]) =
                EARLIER(RELATED(DimDate[MonthSequential])) - 1
        )
    ) = Items[Owner]
    ,1
    ,0
)

There's some funkiness here with row context, which I will explain.

Any calculated column is defined by some formula. That formula is evaluated in the row context of the table. What happens is a row-by-row iteration through the table. The formula you provide is evaluated once per row and that creates the value for that calculated column.

This being said, the storage engine and formula engine behind DAX have no concept of a row ordering. This means that any formula we define for a calculated column must provide its own ordering or reference to another row if we need to do that.

So, what do we do to find the owner in the previous month? Well, we need to look through the entire Items table and find the row which has the same [ItemId] and falls in the month immediately prior to the month on the current row. Our [MonthSequential] makes finding a date in the previous month trivial, and DAX offers many context-manipulating functions to preserve or eliminate context.

Note: I will refer to function arguments positionally, with the first argument to a function indicated by (1).

Let's step through the solution. We'll ignore the IF() because that is trivial. The meat of the formula lies in the CALCULATE() which identifies the [Owner] in the previous month:

CALCULATE(
    VALUES(Items[Owner])
    ,FILTER(
        ALLEXCEPT(Items, Items[ItemID])
        ,RELATED(DimDate[MonthSequential]) =
            EARLIER(RELATED(DimDate[MonthSequential])) - 1
    )
)

CALCULATE() evaluates arguments (2)-(n) first, to create a new filter context. That filter context is then used to evaluate the expression in (1).

FILTER() iterates row-by-row through the table provided in (1) and evaluates the boolean expression in (2) for each row in (1). It returns a table made up of the subset of rows of (1) for which (2) evaluates to true. Since we are already iterating through the entire Items table in evaluating our calculated column, we end up with two sets of row context. The outer row context is the iteration through the whole table. The inner row context is the iteration through (1) of our filter. The outer row context affects the inner, and we must modify/remove select portions of the outer context as needed.

The table we iterate over is ALLEXCEPT(Items, Items[ItemId]). ALLEXCEPT() strips out all context, except for the fields named. On any given row in our outer context, we preserve the value of Items[ItemId] and strip all other context ([Month] and [Owner], along with any other fields you've not named in your sample data). This gives us a table for our FILTER() made up of every row in Items which shares the [ItemId] of the current row in the outer filter context. This subset table becomes the generator of our inner row context.

Now we're iterating over FILTER()'s (1), explained above. RELATED() allows us to call out to get a value from another table related to the current one. We grab the [MonthSequential] value that is tied to the current row in our inner row context. We want to find the month that is immediately prior to the current month in the outer row context. To refer to a value in the outer row context, we need to escape the inner.

EARLIER() allows us to escape the current (inner) row context and refer to the last valid (outer) row context. This can happen through arbitrary levels of nesting of contexts. Luckily, we only have two. EARLIER(RELATED(DimDate[MonthSequential])) finds the [MonthSequential] value of the current row in the outer context. We simply subtract 1 from that to get the prior month (and since we're using [MonthSequential], we have no need to implement any logic to handle wrapping around year barriers).

Thus the context in which we evaluate VALUES(Items[Owner]) is that subset of our Items table where [ItemId] is equal to the current row in our outer row context, and the value of [MonthSequential] is one less than the current row in the outer row context. VALUES() returns the list of values which make up the column reference inside. In this case, since every [ItemId] is associated with only a single [Owner] in any given month, that list is only a single value which can be implicitly cast to a scalar value and represented in our calculated column.

Our IF() simply tests this [Owner] value against that of the current row in the outer row context and returns a 1 or 0 as appropriate.

This will break if you have a single [ItemId] which has multiple distinct [Owner]s in a given month.

Model diagram:

enter image description here