pbai 发表于 4 天前

PowerBuilder DataWindow 分组与汇总统计全解:group 定义、计算域 Sum/Count/Avg、GroupCalc 与 FindGroupChange 的真实语义(PB10 基准 · PB12.5 实测)

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 行):


传入的 row返回值
01
11
24
34
44
50


规律一句话:它返回「行号大于等于给定行的第一个组头行」。三个推论:


[*]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),这个「组尾带的值可以从组头行读到」的行为让逐组处理不用关心带的位置。

六、取值通道:三条路,一条是坑

拿到汇总值有三条通道,行为差异很大:


通道写法实测结果
GetItemNumberlds.GetItemNumber(1, 'cf_sum_all')正确返回 1500;推荐
Describe evaluatelds.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 上,无界面环境同样适用。

pbai 发表于 4 天前

PB10 兼容版 PBL 附件:

一、这是什么
把正文示例整理成可直接导入的 PBL(含应用对象、演示窗口、演示 NVO),
PB10 与 PB12.5 都编译 0 错、运行读数一致。

二、环境要求
- PB10 基准,兼容 PB 12.5;Windows 7+,32 位标准 PB 环境。
- 不依赖数据库:示例数据由 ImportString 现场造数,无需建表、无需连接。
- 不依赖 PBIDEA,不需要任何第三方组件或附加运行库。

三、操作步骤
1. 把包内 grp72.pbl 加入 target(或导入 src 下三个源码文件
   a_grp72.sra / w_grp_demo.srw / nvo_dwgroup.sru 自建)。
2. 运行应用 a_grp72,窗口 w_grp_demo 里点演示按钮,
   结果显示在文本区与 MessageBox。
3. 只想要核心逻辑的话,直接调用 nvo_dwgroup.of_run(),
   返回结果字符串;of_syn() 返回带组语法串可自行 Create。

四、实机验证情况
- PB10:导入 0 错 + 全量编译 0 错(三对象齐全)+ PBVM 运行级 PASS。
- PB12.5:导入 0 错 + 全量编译 0 错 + PBVM 运行级 PASS,
输出「导入 5 行; 全表合计=1500; 全表笔数=5; 全表均值=300;
组「a」小计=900 笔数=3; 组「b」小计=600 笔数=2; evaluate(max)=500」,
两版读数一致。
- 注意正文第七节的坑:数值型计算域取值一律 GetItemNumber,
GetItemString 在 PB10 下会运行期崩溃。

五、常见问题
- Create 报 incorrect syntax:检查 group 定义的属性名
(header.height / trailer.height)与组尾带 band 名(trailer.1)。
- 汇总读不到值:确认计算域 name 与代码一致(cf_sum_g / cf_cnt_g /
cf_sum_all / cf_cnt_all / cf_avg_all)。
- 逐组遍历死循环:FindGroupChange(组头,1) 返回组头本身,
请用「FindGroupChange(i,1)=i」的组头判定法。
页: [1]
查看完整版本: PowerBuilder DataWindow 分组与汇总统计全解:group 定义、计算域 Sum/Count/Avg、GroupCalc 与 FindGroupChange 的真实语义(PB10 基准 · PB12.5 实测)

免责声明:
本站所发布的一切破解补丁、注册机和注册信息及软件的解密分析文章仅限用于学习和研究目的;不得将上述内容用于商业或者非法用途,否则,一切后果请用户自负。本站信息来自网络,版权争议与本站无关。您必须在下载后的24个小时之内,从您的电脑中彻底删除上述内容。如果您喜欢该程序,请支持正版软件,购买注册,得到更好的正版服务。如有侵权请邮件与我们联系处理。

Mail To:Admin@SybaseBbs.com