6.15 PowerBI DAX函数精讲:从CONCATENATEX实战看值、列、表合并的艺术
1. 从“拼接”说起:为什么你需要CONCATENATEX?
做报表的时候,你是不是经常遇到这样的场景?老板指着你的PowerBI看板问:“这个区域明明卖了手机、电脑、平板好几种产品,你这里怎么只显示一个‘电子产品’?我想一眼看到具体是哪些。” 或者,你想在一个单元格里,把筛选后所有销售人员的名字用顿号隔开,形成一个清晰的列表。又或者,你需要把来自不同数据表的客户姓名和最近购买日期,合并成一句“张三(2023-10-01),李四(2023-09-15)”这样的备注信息。
这些需求,本质上都是“合并”。在Excel里,你可能会用&连接符,或者TEXTJOIN函数。但在PowerBI的DAX世界里,面对动态变化的数据模型和复杂的筛选上下文,简单的&往往力不从心。这时候,CONCATENATEX就该登场了。我刚开始用PowerBI时,也总觉得这个函数名字又长又怪,用起来参数也多,有点发怵。但后来在好几个实际项目里被它“拯救”之后,我才发现,这简直是处理动态文本合并的“瑞士军刀”。
简单来说,CONCATENATEX的核心就一句话:它能把一个表里某一列(或某个表达式计算出的值),按照你指定的顺序,用你指定的分隔符,串成一个文本字符串。听起来和TEXTJOIN有点像?最大的区别在于,CONCATENATEX的第一个参数是一个“表”。这个“表”可以是你的数据表,也可以是VALUES、FILTERS、SUMMARIZE等函数返回的一个表。正是因为这个“表”参数,让它能完美地响应报表上的切片器筛选、视觉对象筛选,实现真正动态的、上下文感知的文本合并。
举个例子,你的报表上有一个“大区”切片器。当你选择“华北区”时,CONCATENATEX可以自动合并华北区下所有城市的名字;当你选择“华东区”时,它又动态地变成华东区的城市列表。这种智能,是静态的&连接符完全做不到的。接下来,我们就从一个最常见的销售分析看板场景出发,看看CONCATENATEX如何优雅地解决“值合并”、“列合并”和“表合并”这三类头疼问题。
2. 基础实战:构建一个销售产品分类汇总看板
为了把概念讲清楚,我们设定一个具体的、你可能每天都会碰到的场景:构建一个销售产品分类汇总看板。假设我们有一张销售订单表order_2,里面包含这些字段:产品类别(如“电子产品”、“家具”)、产品子类别(如“手机”、“沙发”)、销售额、销售日期等。
我们的看板需要几个关键的文本合并展示:
- 静态的列合并:创建一个新列,把“产品类别”和“产品子类别”固定地连接起来,比如“电子产品-手机”。
- 动态的值合并:在一个卡片图或表格里,动态地展示当前筛选条件下(比如某个特定月份)所有出现过的“产品子类别”,用分号隔开。
- 跨表的表合并:假设我们还有一张
客户表,我们需要在销售汇总信息旁,合并显示相关的主要客户名称。
这个看板将是我们贯穿全文的“试验田”。我会一步步带你操作,并解释每一步背后的DAX逻辑。别担心参数复杂,我们先从最简单的“列合并”开始,就像热身一样。
2.1 列合并:创建静态的分类标签
“列合并”是最直观的一种。它的目的不是动态响应筛选,而是在数据建模阶段,就生成一个新的、固定的文本列。比如,你想把产品类别和产品子类别合并成一个完整的分类标签,方便后续在图表中作为轴标签使用。
在PowerBI Desktop里,你可以在“表视图”中,通过“新建列”来实现。DAX公式非常简单,用的就是我们熟悉的&连接符:
产品分类标签 = 'order_2'[产品类别] & "-" & 'order_2'[产品子类别]这行代码为order_2表的每一行都创建了一个新列。如果某行数据是“电子产品”类别下的“手机”子类,那么这一列的值就是“电子产品-手机”。这个值是静态的,一旦数据刷新计算完成,它就固定下来了,不会因为报表页面的切片器选择而变化。
什么时候用列合并?我个人的经验是,当你需要创建一个稳定的、用于分组或分类的维度时,就用它。比如,在做数据透视表或者作为图表轴标签时,这种合并后的字段比单独的两个字段更清晰。但它的局限性也很明显:不灵活。如果老板突然想看到“子类别-类别”的格式,你就得回去修改公式并刷新整个数据模型。
2.2 值合并:动态拼接当前可见的子类列表
这才是CONCATENATEX大显身手的地方,也是新手最容易感到困惑的地方。所谓“值合并”,是指根据当前报表的筛选上下文,动态地将一列中的“值”合并成一个字符串。
回到我们的看板。我们想在报表上放一个卡片图,或者在一个表格的汇总行里,显示“当前销售涉及的产品子类别有哪些”。比如,当用户用切片器筛选了“2023年10月”的数据,这个卡片就应该动态显示10月份有销售的所有产品子类别,比如“手机;电脑;平板;沙发;椅子”。
这时候,你需要创建一个“度量值”,而不是“新建列”。因为度量值是动态计算的,会随着筛选上下文改变而改变。
当前产品子类列表 = CONCATENATEX( VALUES('order_2'[产品子类别]), // 第一部分:要遍历的表 'order_2'[产品子类别], // 第二部分:要合并的表达式 ";" // 第三部分:分隔符 )我们来拆解这个公式:
VALUES('order_2'[产品子类别]):这是CONCATENATEX的第一个参数,也是最关键的部分。VALUES函数会返回当前筛选上下文中,产品子类别这一列的所有“可见值”(去重后的列表)。也就是说,如果筛选了10月份的数据,VALUES返回的就是10月份销售记录里出现过的所有子类别的唯一值表。'order_2'[产品子类别]:这是第二个参数,即对上面那个表中的每一行,要取什么值来合并。这里我们直接取子类别本身。";":这是第三个参数,分隔符。你可以用逗号、顿号、换行符UNICHAR(10)等等。
把这个度量值拖入一个卡片图,你就会发现它的魔力了:切换不同的时间筛选器,卡片里的文本内容会实时变化。这就是“动态”的含义。VALUES函数在这里起到了“桥梁”作用,它把当前的筛选上下文转化成了一个CONCATENATEX可以处理的“表”。
踩坑提醒:这里有个初学者常犯的错误。第二个参数‘order_2‘[产品子类别],它必须能够在你第一个参数VALUES返回的那个“表”的上下文中被正确计算。因为VALUES返回的表,其行上下文就是产品子类别的每一个值。所以直接写列名是没问题的。但如果你在这里写一个复杂的、需要其他列参与的表达式,就要特别注意计算上下文了。
2.3 进阶:VALUES vs. FILTERS,一字之差天壤之别
上面我们用到了VALUES,但DAX里还有个兄弟函数叫FILTERS。在CONCATENATEX的第一个参数里,用VALUES还是FILTERS,结果可能完全不同。这是理解DAX筛选上下文的一个绝佳案例。
我画个简单的图帮你理解:
VALUES(列):返回的是当前筛选上下文中,该列实际存在于数据表里的值。它关注“数据里有什么”。FILTERS(列):返回的是当前筛选上下文直接施加在该列上的筛选器值。它关注“切片器选了啥”。
听起来有点绕?我们举个实例。假设我们的产品子类别切片器里有“手机”、“电脑”、“平板”、“沙发”、“椅子”五个选项,但10月份的实际销售数据只包含“手机”、“电脑”和“沙发”。
场景A:用户在切片器里什么都没选(即全选状态)。
VALUES('order_2'[产品子类别])返回:{"手机", "电脑", "沙发"}(因为10月数据只有这三个)。FILTERS('order_2'[产品子类别])返回:{"手机", "电脑", "平板", "沙发", "椅子"}(因为切片器全选,所有选项都是生效的筛选器)。- 此时,用
VALUES的度量值显示“手机;电脑;沙发”,而用FILTERS的会显示“手机;电脑;平板;沙发;椅子”。后者包含了未产生销售的数据“平板”和“椅子”。
场景B:用户在切片器里只选择了“手机”和“平板”。
VALUES('order_2'[产品子类别])返回:{"手机"}(因为筛选后,数据里只有“手机”符合,“平板”没有销售记录)。FILTERS('order_2'[产品子类别])返回:{"手机", "平板"}(切片器选了什么就返回什么)。- 此时,用
VALUES的度量值只显示“手机”,而用FILTERS的显示“手机;平板”。
看出区别了吗?VALUES更贴近“事实”,而FILTERS更反映“意图”。在实际应用中:
- 如果你想合并实际发生了业务的值(比如已销售的产品、有交易的客户),用
VALUES。 - 如果你想合并用户当前主动筛选的值(比如切片器里勾选的项目,无论是否有数据),用
FILTERS。
理解了这个,你就能避免出现“为什么我选了三个,只合并出两个?”这类疑惑了。
3. 玩转参数:排序与复杂表达式合并
掌握了基础用法和VALUES/FILTERS的区别,你已经能解决80%的问题了。但CONCATENATEX的强大之处在于它的可定制性,特别是排序和合并复杂表达式的能力。
3.1 让合并结果井然有序:排序参数详解
默认情况下,CONCATENATEX合并值的顺序是不确定的,通常取决于数据底层存储顺序。这对于展示来说很不友好。幸好,它提供了第四、第五个参数来指定排序。
语法是这样的:CONCATENATEX(表, 表达式, 分隔符, [排序依据表达式], [排序方式])
其中,排序方式可以是ASC(升序,默认)或DESC(降序)。排序依据表达式可以是你想合并的列本身,也可以是其他任何相关的列或度量值。
场景1:按产品子类别名称字母排序
有序产品子类列表 = CONCATENATEX( VALUES('order_2'[产品子类别]), 'order_2'[产品子类别], ";", 'order_2'[产品子类别], // 按子类别名字本身排序 ASC )这样,输出就会是“电脑;沙发;手机”这样按拼音或字母顺序排列的列表。
场景2:按销售额从高到低排序,合并产品子类这个需求更常见也更有业务意义:把卖得最好的几个产品子类列出来。
按销售额排序的子类列表 = CONCATENATEX( SUMMARIZE( FILTER('order_2', [销售额] > 0), 'order_2'[产品子类别] ), // 先按子类别汇总 'order_2'[产品子类别], ";", [销售额], // 排序依据是当前上下文下的销售额度量值! DESC )这里有几个关键点:
- 第一个参数不再是简单的
VALUES,而是先用SUMMARIZE和FILTER构建了一个包含产品子类别和对应销售额汇总的表。因为CONCATENATEX的排序依据需要是一个能在其迭代的每一行中计算的值。 - 第二个参数依然是合并的内容。
- 第四个参数
[销售额]是一个度量值,它会在SUMMARIZE生成的每一行(每个子类别)的上下文中计算该子类别的总销售额,并以此作为排序依据。 - 第五个参数
DESC表示降序,销售额最高的排前面。
这样,输出的字符串可能就是“手机;电脑;平板”,完美反映了销售贡献度排名。
3.2 超越简单列:合并自定义表达式
CONCATENATEX的第二个参数表达式非常灵活,它不限于直接引用列,可以是任何返回标量值的DAX表达式。这意味着你可以合并更丰富的信息。
场景:合并“产品子类别(销售额)”比如,你想合并成“手机(¥15,000);电脑(¥12,000);沙发(¥8,000)”这样的格式。
子类及销售额合并 = CONCATENATEX( FILTER( SUMMARIZE('order_2', 'order_2'[产品子类别]), [销售额] > 0), 'order_2'[产品子类别] & "(" & FORMAT([销售额], "¥#,##0") & ")", ";", [销售额], DESC )这个公式做了几件事:
- 第一个参数,我们构建了一个包含有效销售额的子类别表。
- 第二个参数是一个表达式:它将
产品子类别、格式化后的销售额度量值用括号连接起来。FORMAT函数用来将数字转换成带货币符号和千位分隔符的文本。 - 第四、五个参数指定按销售额降序排列。
这个度量值生成的结果,信息量就比单纯合并名字大得多,可以直接用在报表的标题或说明文字中,让读者一目了然。
4. 高阶应用:表合并与跨表信息拼接
“表合并”这个概念,在这里不是指合并查询(Merge),而是指基于更复杂的表关系,从多个相关表中提取信息,合并成一个字符串。这是CONCATENATEX更高级的用法,能实现一些非常酷的效果。
4.1 跨表合并:拼接客户与订单信息
假设我们除了order_2销售表,还有一张customer客户表,两者通过客户ID关联。现在,我们想在每个产品类别的汇总行,显示购买过该类产品的主要客户名单。
这个需求无法通过简单的列合并或值合并实现,因为它涉及跨越两个表的逻辑。我们需要:
- 确定当前上下文(比如某个产品类别)。
- 找到这个类别下所有的销售记录。
- 通过这些销售记录对应的
客户ID,去customer表找到客户姓名。 - 将这些客户姓名合并起来。
购买客户列表 = VAR CurrentCategory = SELECTEDVALUE('order_2'[产品类别]) // 获取当前上下文的产品类别 VAR RelatedCustomers = CALCULATETABLE( VALUES(customer[客户姓名]), // 获取相关的客户姓名 TREATAS( VALUES('order_2'[客户ID]), customer[客户ID] ) // 建立临时的关系筛选 ) RETURN IF( NOT ISBLANK(CurrentCategory), CONCATENATEX( RelatedCustomers, customer[客户姓名], ",", customer[客户姓名], ASC ), "(未选择类别)" )这个公式稍微复杂一些,我们拆解一下:
VAR用于定义变量,让公式更清晰。CurrentCategory变量获取当前单元格或视觉对象所在的产品类别。RelatedCustomers变量是核心。它使用CALCULATETABLE和TREATAS函数,模拟了一个临时的关系:将当前order_2表中涉及到的客户ID,作为筛选器应用到customer表上,从而得到相关的客户姓名表。这是一种在度量值中动态建立表关系的常用技巧。- 最后,用
CONCATENATEX合并RelatedCustomers表中的客户姓名,并按姓名升序排列。 IF判断用于处理未选择类别时的显示。
把这个度量值放在一个以产品类别为行的矩阵表中,你就能在每个类别旁边看到对应的客户列表了。
4.2 生成动态的摘要或注释文本
CONCATENATEX的另一个强大用途是生成动态的文本摘要,直接作为报表的标题、副标题或注释。比如,你可以创建一个度量值,用来动态生成当前报表的筛选状态描述:
报表筛选状态 = "当前报表数据范围:" & CONCATENATEX( VALUES('Date'[Year]), 'Date'[Year], "年、") & "年,产品类别包括:" & CONCATENATEX( VALUES('order_2'[产品类别]), 'order_2'[产品类别], "、") & "。"这个度量值会随着你对年份和产品类别的筛选,自动更新文本内容。例如,当你筛选了2023年和2024年,以及“电子产品”和“家具”类别时,它会显示:“当前报表数据范围:2023年、2024年,产品类别包括:电子产品、家具。” 这对于制作交互式报告、增强报告的可读性和用户体验非常有帮助。
5. 避坑指南与性能优化
功能强大也意味着使用不当会带来问题。根据我多年的实战经验,这里有几个常见的“坑”和优化建议。
坑1:合并结果出现空白或重复的项这通常是因为第一个参数(表)中包含了空白行。在DAX中,如果关系缺失或计算返回空值,可能会产生空白行。你可以在CONCATENATEX外层使用FILTER来过滤掉空值:
CONCATENATEX( FILTER( VALUES('order_2'[产品子类别]), NOT ISBLANK('order_2'[产品子类别]) ), ... )对于重复项,确保你使用的表函数(如VALUES,DISTINCT)本身是返回唯一值的。VALUES在关系完整时通常返回唯一值。
坑2:数据量巨大时性能变慢CONCATENATEX需要迭代第一个参数表中的每一行进行计算。如果这个表有上万行,且用在多个视觉对象中,可能会影响报表性能。
- 优化建议1:尽量避免对非常大的表直接使用
CONCATENATEX。先通过筛选上下文或SUMMARIZE等函数将数据缩减到必要的最小集合。 - 优化建议2:考虑是否真的需要合并所有值。有时,合并前N个(例如销售额前5的产品)可能更有意义,这可以通过
TOPN函数配合CONCATENATEX实现。 - 优化建议3:将结果缓存为计算列(如果合并逻辑相对静态)。但这牺牲了动态性,需权衡。
坑3:在迭代函数中使用不当CONCATENATEX本身是一个迭代器。如果你把它放在另一个迭代函数(如SUMX,FILTER内部)中,可能会造成嵌套迭代,导致性能急剧下降。在设计度量值时,要思考清楚计算逻辑的层次。
一个实用的调试技巧:当你写的CONCATENATEX公式没有返回预期结果时,可以分步调试。先单独创建一个度量值,只返回第一个参数的表(比如= COUNTROWS(VALUES(...))),看看这个表里是不是你期望的行。再检查第二个表达式在每行里计算是否正确。逐层排查,能快速定位问题所在。
6. 融会贯通:一个综合案例
让我们把所有知识串起来,解决一个更复杂的真实需求。假设老板想要一个销售仪表板,其中有一个关键指标卡,需要显示以下信息: “本期核心销售贡献来自[按销售额降序排列的前3个子类别],其销售额占比为XX%。主要购买客户包括:[相关客户列表]。”
这个需求包含了动态筛选、排序、取前N项、跨表合并和文本格式化。我们可以创建如下度量值:
核心销售摘要 = VAR TotalSales = [销售额] // 定义总销售额度量值 VAR Top3Subcategories = TOPN( 3, SUMMARIZE( FILTER(ALLSELECTED('order_2'), [销售额] > 0), 'order_2'[产品子类别] ), [销售额], DESC ) // 获取销售额前三的子类别表 VAR SalesFromTop3 = CALCULATE( [销售额], KEEPFILTERS(Top3Subcategories) ) // 计算前三子类别的销售额 VAR Top3Names = CONCATENATEX( Top3Subcategories, 'order_2'[产品子类别], "、", [销售额], DESC ) // 合并前三名称 VAR Percentage = DIVIDE(SalesFromTop3, TotalSales, 0) // 计算占比 VAR RelatedCustomers = CALCULATETABLE( DISTINCT(customer[客户姓名]), TREATAS( VALUES('order_2'[客户ID]), customer[客户ID] ), KEEPFILTERS(Top3Subcategories) // 只关联前三子类别的客户 ) VAR CustomerList = IF( NOT ISEMPTY(RelatedCustomers), CONCATENATEX( TOPN(5, RelatedCustomers, [销售额], DESC), customer[客户姓名], "、"), "(暂无客户数据)" ) // 合并销售额前5的主要客户 RETURN "本期核心销售贡献来自 " & Top3Names & ",其销售额占比为 " & FORMAT(Percentage, "0.0%") & "。主要购买客户包括:" & CustomerList & "。"这个度量值虽然长,但结构清晰,每一步都用VAR变量拆解。它综合运用了TOPN、SUMMARIZE、CALCULATE、TREATAS以及CONCATENATEX。把它放入一个卡片图,它就能根据全局筛选器(如时间、区域),动态生成一段完整的、数据驱动的业务描述。这种动态文本生成能力,能将你的报表从简单的图表展示,提升到智能业务叙述的层次。
从我自己的项目经验来看,CONCATENATEX这类文本函数用得好,能极大提升报表的自动化水平和可读性。它让报表不再是冷冰冰的数字和图形,而是能“说话”、能“解释”数据的智能文档。刚开始接触时多写几个例子,遇到报错时耐心拆解每个参数返回的表是什么,慢慢你就会发现,处理复杂的文本合并需求时,思路会变得非常清晰。
