马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?站点注册
×
PowerBuilder DataWindow 分组与汇总统计全解:group 定义、计算域 Sum/Count/Avg、GroupCalc 与 FindGroupChange 的真实语义(PB10 基准 · PB12.5 实测)
阅读说明
1. 适用版本:基准 PB10,兼容 PB 12.5(本文所有代码两版实机验证,差异之处单独标注)
2. 支持数据库:本文示例不依赖数据库(用 ImportString 造数);语法串里的 retrieve 仅是占位
3. 操作系统与环境要求:Windows 7 及以上;PB 10 或 PB 12.5 开发环境即可,无需任何第三方运行库
4. 难度系数:★★★☆☆(需要理解 DataWindow 的带(band)与缓冲区概念)
5. 其它阅读说明:建议先读《DataWindow 三缓冲区详解》与《排序与过滤全解》两篇;示例用 DataStore 演示,结论同样适用于窗口里的 DataWindow 控件
DataWindow 的分组汇总,是报表开发里最常用的能力:按部门小计、按月份合计、页脚总计,全靠它。但这块知识的「真实行为」和很多人想象的不一样:组不是靠肉眼看到的分隔线定义的,FindGroupChange 也不是文档语焉不详的那种「返回下一组行号」,计算域的取值通道还有版本差异。这一篇把分组统计的完整链路——组定义、聚合表达式、求值时机、组头定位——全部实机跑一遍,两版对照,把坑一条条摆出来。
一、三件事分清楚:组、计算域、聚合表达式
分组统计体系里有三个容易混的对象:
- 组(group):数据窗口的分组定义,声明「按哪个列分组」。语法层面是 group(level=1 ... by=("dept" ) ) 这样一条定义。
- 计算域(computed field):放在某个带里的对象,语法层面是 compute(band=... expression="..." name=...)。它不是列,没有缓冲区,值是表达式算出来的。
- 聚合表达式:写在计算域的 expression 里的那串 sum(salary for group 1)。for all 是全表、for group 1 是第一级组内。
三者关系:组负责「把行分堆」,计算域负责「摆在哪条带里」,聚合表达式负责「算什么」。画笔里你拖一个 Sum 小计进组尾带,落下来的就是一条 compute(band=trailer.1 ...)。
二、最小可运行骨架:动态创建一个带组的 DataStore
先给一个能直接跑的最小语法串。它是真实测过的:PB10 与 PB12.5 都能 Create 成功并算出正确汇总。读者可以直接抄:
- // 带组与汇总计算域的最小 DataWindow 语法(PB10/PB12.5 双版实测通过)
- // 三个列:dept(部门)、emp(姓名)、salary(工资);按 dept 分组
- // 组尾带放小计 cf_sum_g,summary 带放全表合计 cf_sum_all 与全表笔数 cf_cnt_all
- string ls_syn
- long ll_rc
- ls_syn = 'release 10; datawindow(units=0 timer_interval=0 color=1073741824 processing=1 ) ' + &
- 'table(column=(type=char(10) name=dept dbname="t.dept") column=(type=char(10) name=emp dbname="t.emp") ' + &
- 'column=(type=long name=salary dbname="t.salary") retrieve="select dept, emp, salary from t where 1=0" ' + &
- 'sort="dept A " ) ' + &
- 'header(height=68) detail(height=80) summary(height=68) ' + &
- 'group(level=1 header.height=68 trailer.height=64 by=("dept" ) ) ' + &
- 'compute(band=trailer.1 x="9" y="4" height="56" width="100" ' + &
- 'expression="sum(salary for group 1)" name=cf_sum_g) ' + &
- 'compute(band=summary x="9" y="4" height="56" width="100" ' + &
- 'expression="sum(salary for all)" name=cf_sum_all) ' + &
- 'compute(band=summary x="120" y="4" height="56" width="100" ' + &
- 'expression="count(salary for all)" name=cf_cnt_all)'
- datastore lds
- lds = create datastore
- ll_rc = lds.Create(ls_syn, ls_err) // 成功返回 1,失败返回 -1 且 ls_err 带行号列号的错误文本
- if ll_rc <> 1 then
- MessageBox('创建失败', ls_err)
- return
- end if
复制代码
注意 ls_err 要在 Create 之前声明(string ls_err),上面为省篇幅只提示一句,完整可运行版见文末示例。这条语法串里有三个关键点:
- table(...) 里的 sort="dept A ":组的排序依据写在这里,不是写在 group 定义里。
- group(level=1 header.height=68 trailer.height=64 by=("dept" ) ):level 是组级号(多层分组就有 group(level=1)、group(level=2)……),by 指定分组列。注意属性名是 header.height、trailer.height——不是 header.level.1.height,后者直接报「incorrect syntax」,这是我第一版语法串踩的坑。
- 组尾带的 band 名是 trailer.1(点加组级号),不是 trailer.level.1。summary 带就叫 summary。
三、聚合表达式语法:sum / count / avg / max / min
计算域表达式里常用的聚合函数,实测全部可用:
| 表达式 | 含义 | 实测读数(五行测试数据) | | sum(salary for all) | 全表合计 | 1500 | | sum(salary for group 1) | 第一级组内合计 | 900(a 组) | | count(salary for all) | 全表计数 | 5 | | count(emp for group 1) | 组内计数 | 3(a 组) | | avg(salary for all) | 全表平均 | 300 | | max(salary for all) | 最大值 | 500 | | min(salary for all) | 最小值 | 100 |
两点说明。第一,for group 1 里的数字是组级号,多层分组时 for group 2 就是内层组。第二,count(列名) 数的是该列非空的行数,所以 count(emp for group 1) 和 count(salary for group 1) 在有空值时会不一样,选列时要想清楚数的是什么。
四、求值时机:什么时候这些计算域里已经有值了
这是实测中最有价值的一组结论。测试数据五行走交错导入(a、b、a、b、a),结果:
- ImportString 一结束,全表汇总域就有正确值:GetItemNumber(1, 'cf_sum_all') 直接读到 1500,不需要任何额外调用。
- 导入后数据已经是按组排好序的:交错导入的五行走,GetItemString 逐行读出来是 a/a/a/b/b——table 里的 sort="dept A " 在导入时就生效了,不是等 GroupCalc 才排。
- 组小计域同样立即可读:第一行所在组(a 组)的小计 900,在没调 GroupCalc 之前读就是对的。
- SetItem 改数据后聚合立即刷新:把第一行工资从 100 改成 999,全表合计立刻从 1500 变 1999,不等刷新、不等 GroupCalc。
- GroupCalc 是幂等的:返回 1(成功),再调一次还是 1,组结构不变、数据顺序不变、小计不变。
所以常规用法里你几乎不需要手动调 GroupCalc——数据变动(导入、SetItem、排序)之后聚合值都是即时正确的。GroupCalc 的用武之地是那些「结构变了但系统可能不知道」的场合,比如用 Modify 动态改了组定义之后。
五、FindGroupChange 的真实语义(本篇最重要的一条实测)
FindGroupChange 是遍历分组的标准 API,但它的行为和多数人想的不一样。我把五个数据行逐行喂给它,把返回值全部记下来(数据排序后 a 组占第 1~3 行,b 组占第 4~5 行):
规律一句话:它返回「行号大于等于给定行的第一个组头行」。三个推论:
- FindGroupChange(0, 1) 返回第一组的组头行(1),这是它最常见的用法。
- 传入组头本身,返回的还是这个组头(FGC(1)=1)——不是「下一组」。想拿下一组,传组内最后一行的下一行(FGC(4)=4 拿到 b 组头,因为 a 组到第 3 行为止)。
- 超过最后一组的组头之后(FGC(5)),返回 0。空表时 FindGroupChange(0, 1) 也是 0。
于是「从组头往下走」的写法会死循环:ll_row = FindGroupChange(1, 1) 永远返回 1。实测里我第一版遍历循环就是这么写的,五次迭代全卡在第一组。正确姿势有两条路,第一条用「组头判定」:
- // 组头判定法:FindGroupChange(i, 1) 等于 i 本身,说明第 i 行是一个组头
- long ll_i, ll_sum, ll_cnt
- for ll_i = 1 to lds.RowCount()
- if lds.FindGroupChange(ll_i, 1) = ll_i then
- ll_sum = lds.GetItemNumber(ll_i, 'cf_sum_g') // 组小计:读组头行上的组尾带计算域
- ll_cnt = lds.GetItemNumber(ll_i, 'cf_cnt_g')
- // 这里就是一组:ll_i 是组头行,ll_sum 是组内合计,ll_cnt 是组内笔数
- end if
- end for
复制代码
第二条更朴素,直接比较分组列的值(前提是数据已按组排序,上一节证明了这一点):
- // 相邻比较法:分组列的值变了,就是新组开始
- string ls_prev, ls_cur
- long ll_i
- ls_prev = ''
- for ll_i = 1 to lds.RowCount()
- ls_cur = lds.GetItemString(ll_i, 'dept')
- if ls_cur <> ls_prev then
- // 新组开始,组头行是 ll_i
- end if
- ls_prev = ls_cur
- end for
复制代码
两种我都实测过,结果一致。组头行上读 cf_sum_g 拿到的是该组小计(a 组组头行读出 900,b 组组头行读出 600),这个「组尾带的值可以从组头行读到」的行为让逐组处理不用关心带的位置。
六、取值通道:三条路,一条是坑
拿到汇总值有三条通道,行为差异很大:
| 通道 | 写法 | 实测结果 | | GetItemNumber | lds.GetItemNumber(1, 'cf_sum_all') | 正确返回 1500;推荐 | | Describe evaluate | lds.Describe("evaluate('sum(salary for all)', 1)") | 返回字符串 '1500',可现场算任意聚合;推荐 | | Describe 按名 | lds.Describe('cf_sum_all') | 恒返回 !(既不是值也不是属性,等于没这个名字)——别用 |
Describe evaluate 是最灵活的一条:evaluate('max(salary for all)', 1)、evaluate('sum(salary for group 1)', 3) 都能算,第二个参数传任意行号,聚合只受表达式影响、不受该行影响(实测传 1 和传 5,全表合计都是 1500)。返回的是字符串,数值比较前要转换。
坑:用 GetItemString 去读数值型计算域,PB12.5 返回空字符串(不报错),PB10 直接运行期崩溃——诊断文件一字未写、进程退出,TRY-CATCH 也兜不住。这是本文唯一的运行期版本差异,记住一条就行:数值计算域永远用 GetItemNumber,字符串拼接显示交给 String() 函数。
七、坏列表达式:Create 不报错,算的时候才炸
把表达式写错列名(sum(nosuchcol for all))再 Create,实测 Create 照样返回 1,错误串是空的——语法层完全不校验列名。导入数据也正常。到了取值环节才分出两种下场:
- Describe("evaluate('sum(nosuchcol for all)', 1)"):两版都不崩,返回 !。这算是给了个温和的信号。
- GetItemNumber(1, 'cf_sum_all'):两版都崩溃,进程直接退出,TRY-CATCH 抓不住。PB10、PB12.5 各自独立复现。
所以「Create 成功」绝不等于「表达式正确」。写完计算域,务必用 evaluate 探一下再交给 GetItemNumber,或者干脆在开发阶段就用画笔预览一遍——预览时坏表达式会立刻现形。
八、PB12.5 差异
本文除一处外,两版行为完全一致(13 组探针的输出逐字节相同):
- GetItemString 读数值型计算域:PB12.5 返回空字符串;PB10 运行期崩溃(见第六节)。口径:PB10 崩溃、PB12.5 静默空串,两版都不可用。
- 另有一个编译级差异顺带记录:同一个函数里重复声明同名变量(比如公共前奏声明过 long ll_n,函数体里又声明一次),PB10 报 C0081 拒绝编译,PB12.5 放行。写两版通吃的代码,声明习惯要干净。
九、完整示例(可运行,输出到 MessageBox)
把上文串起来:建带组的 DataStore、交错导入五行、读全表汇总、逐组输出小计、再用 evaluate 现场算最大值。
- // 分组汇总演示:不连数据库,ImportString 造数
- // 输入数据:dept=部门,emp=姓名,salary=工资(硬编码五行,a/b 两部门交错)
- // 运行前提:PB10 或 PB12.5,无需数据库、无需第三方库
- datastore lds
- string ls_syn, ls_err, ls_out
- long ll_rc, ll_imp, ll_i, ll_sum, ll_cnt
- ls_err = ''
- ls_syn = 'release 10; datawindow(units=0 timer_interval=0 color=1073741824 processing=1 ) ' + &
- 'table(column=(type=char(10) name=dept dbname="t.dept") column=(type=char(10) name=emp dbname="t.emp") ' + &
- 'column=(type=long name=salary dbname="t.salary") retrieve="select dept, emp, salary from t where 1=0" ' + &
- 'sort="dept A " ) ' + &
- 'header(height=68) detail(height=80) summary(height=68) ' + &
- 'group(level=1 header.height=68 trailer.height=64 by=("dept" ) ) ' + &
- 'compute(band=trailer.1 x="9" y="4" height="56" width="100" ' + &
- 'expression="sum(salary for group 1)" name=cf_sum_g) ' + &
- 'compute(band=trailer.1 x="120" y="4" height="56" width="100" ' + &
- 'expression="count(emp for group 1)" name=cf_cnt_g) ' + &
- 'compute(band=summary x="9" y="4" height="56" width="100" ' + &
- 'expression="sum(salary for all)" name=cf_sum_all) ' + &
- 'compute(band=summary x="120" y="4" height="56" width="100" ' + &
- 'expression="count(salary for all)" name=cf_cnt_all) ' + &
- 'compute(band=summary x="240" y="4" height="56" width="100" ' + &
- 'expression="avg(salary for all)" name=cf_avg_all)'
- lds = create datastore
- ll_rc = lds.Create(ls_syn, ls_err)
- if ll_rc <> 1 then
- MessageBox('创建失败', ls_err)
- destroy lds
- return
- end if
- // 五行测试数据:a/b 部门交错导入,导入后自动成组有序
- ll_imp = lds.ImportString('a~te1~t100~r~nb~te2~t200~r~na~te3~t300~r~nb~te4~t400~r~na~te5~t500')
- if ll_imp <= 0 then
- MessageBox('导入失败', 'ImportString 返回 ' + String(ll_imp))
- destroy lds
- return
- end if
- ls_out = '导入 ' + String(ll_imp) + ' 行; '
- // 全表汇总直接读 summary 带计算域
- ls_out = ls_out + '全表合计=' + String(lds.GetItemNumber(1, 'cf_sum_all'))
- ls_out = ls_out + '; 全表笔数=' + String(lds.GetItemNumber(1, 'cf_cnt_all'))
- ls_out = ls_out + '; 全表均值=' + String(lds.GetItemNumber(1, 'cf_avg_all'))
- // 逐组小计:组头判定 = FindGroupChange(i,1) 等于 i 本身
- for ll_i = 1 to lds.RowCount()
- if lds.FindGroupChange(ll_i, 1) = ll_i then
- ll_sum = lds.GetItemNumber(ll_i, 'cf_sum_g')
- ll_cnt = lds.GetItemNumber(ll_i, 'cf_cnt_g')
- ls_out = ls_out + '; 组「' + lds.GetItemString(ll_i, 'dept') + '」小计=' + String(ll_sum) + ' 笔数=' + String(ll_cnt)
- end if
- end for
- // evaluate 现场算任意聚合(返回字符串)
- ls_out = ls_out + '; evaluate(max)=' + lds.Describe("evaluate('max(salary for all)', 1)")
- MessageBox('分组汇总结果', ls_out)
- destroy lds
- return
复制代码
本机实测输出:导入 5 行; 全表合计=1500; 全表笔数=5; 全表均值=300; 组「a」小计=900 笔数=3; 组「b」小计=600 笔数=2; evaluate(max)=500。PB10 与 PB12.5 读数一致。
窗口里用 DataWindow 控件的读者:把 datastore 换成数据窗口控件、Create 那段换成 SetTransObject 加 Retrieve 即可,其余取值与遍历逻辑一字不用改。画笔用户则更简单:设计器里「Rows - Create Group」选分组列,拖计算域进组尾带写 sum(salary for group 1),落到源码里就是本文第二节的语法。
十、坑清单(每条都实机踩过)
- group 定义的属性名是 header.height / trailer.height,写 header.level.1.height 直接「incorrect syntax」。
- 组尾带对象的 band 名是 trailer.1(组级号跟在点后面),summary 带就叫 summary。
- 组的排序依据在 table(... sort=...) 里,不在 group 定义里;实测导入后数据即按此排序成组。
- ImportString 后全表汇总与组小计立即有值,不需要 GroupCalc;GroupCalc 幂等(连调两次都返回 1)。
- SetItem 改数后聚合立即刷新(1500→1999 实测),无延迟。
- FindGroupChange 返回「大于等于给定行的第一个组头行」,传入组头返回组头本身——从组头往下走的遍历会死循环。
- 组头判定用 FindGroupChange(i,1) = i;空表 FindGroupChange(0,1) 返回 0。
- 组尾带计算域的值可以从该组任意行读到(实测组头行读出整组小计),但建议统一从组头行读,语义最清晰。
- Describe('计算域名') 恒返回 !,取计算域只有 GetItemNumber 和 Describe evaluate 两条正路。
- Describe evaluate 返回字符串,参与数值运算前必须转换。
- GetItemString 读数值型计算域:PB12.5 返空串,PB10 直接崩——数值计算域只用 GetItemNumber。
- 坏列名的表达式 Create 不报错(错误串为空),evaluate 返回 !,GetItemNumber 双版崩溃——Create 成功不代表表达式正确。
- count 数的是该列非空行数,count(emp...) 与 count(salary...) 在有空值时结果不同。
- 两版通吃的代码里别重复声明同名变量:PB10 报 C0081,PB12.5 却放行。
十一、相关阅读
- 《PowerBuilder DataWindow 三缓冲区详解》——计算域不在缓冲区里,理解这一点再看本文第六节会更透。
- 《PowerBuilder DataWindow 排序与过滤全解》——table 里的 sort 与 SetSort 的关系、Sort 的不稳定排序,本文第四节「导入即有序」的另一半。
- 《PowerBuilder DataWindow 导入导出全解》——ImportString 的分隔符规则与 -4 脏行坑,本文示例的造数方法来自那篇。
- 《PowerBuilder DataStore 后台批处理》——本文全部示例跑在 DataStore 上,无界面环境同样适用。
|