使用Firefox访问Lex下的VF页面时csv下载失败的解决办法

众所周知,如果打算做一个纯前端的下载CSV的功能,可以使用如下代码。

let content = "test,test1\nabc,123";
let blob = new Blob([content], { type: 'text/csv' });
let csvUrl = URL.createObjectURL(blob);
let elementLink = document.createElement('a');
elementLink.href = csvUrl;
elementLink.target = '_blank';
elementLink.download = 'test.csv';
elementLink.click();

在DOM中新建了临时的a标签,并执行点击动作。之后就会把content的内容作为csv文件下载下来。
无论是在VF里还是Lightning Component中皆可使用。

在VF页面中嵌入这段代码之后,使用Chrome在Classic与Lex下使用都是正常的。
但是使用Firefox在Classic下正常,在Lex下却会报错失败。
比如在我的实验环境,错误信息为如下。

Content Security Policy: The page’s settings blocked the loading of a resource at blob:https://snrenv2-dev-ed–c.ap4.visual.force.com/016d4701-1e33-47a1-8099-eaec82c7ff1d (“frame-src”).

唔。。。Content Security Policy。(关于什么是CSP,可以参考阮一峰老师的文章或者MDN文档(什么是内容安全策略CSP用法)。
而且是来自于frame-src的CSP设定。

那么就一定存在iframe。
还记得在Classic时代,VF页面的主域名和标准页面的主域名是不同的。
VF:https://snrenv2-dev-ed–c.ap4.visual.force.com/
非VF:https://snrenv2-dev-ed.my.salesforce.com/

Lightning时代可能为了解决这个问题,在Lex的时候,居然将VF嵌套进了iframe里???

图例

这样的话,让我们来看看Salesforce的CSP是怎么设定的。F12->网络->respones header。

Content-Security-Policy:
default-src ‘self’; script-src ‘self’ ‘nonce-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx’ chrome-extension: ‘unsafe-inline’ ‘unsafe-eval’ *.canary.lwc.dev *.ap4.visual.force.com https://ssl.gstatic.com/accessibility/; object-src ‘self’ https://snrenv2-dev-ed–c.ap4.content.force.com; style-src ‘self’ blob: chrome-extension: ‘unsafe-inline’ *.ap4.visual.force.com https://snrenv2-dev-ed–c.ap4.content.force.com; img-src ‘self’ http: https: data: blob: *.ap4.visual.force.com; media-src ‘self’ *.ap4.visual.force.com https://snrenv2-dev-ed–c.ap4.content.force.com blob:; frame-ancestors ‘self’; frame-src https: mailto: *.ap4.visual.force.com; font-src ‘self’ https: data: *.ap4.visual.force.com; connect-src ‘self’ https://api.bluetail.salesforce.com https://staging.bluetail.salesforce.com https://preprod.bluetail.salesforce.com *.ap4.visual.force.com https://snrenv2-dev-ed–c.ap4.content.force.com https://ap4.salesforce.com

可以看到针对iframe的设定是允许https, mailto, vf域名。这明明vf域名是合法的啊。
按照最上面的写法,最后生成的url是blob:https://snrenv2-dev-ed–c.ap4.visual.force.com/016d4701-1e33-47a1-8099-eaec82c7ff1d,甚至将https://snrenv2-dev-ed–c.ap4.visual.force.com/016d4701-1e33-47a1-8099-eaec82c7ff1d直接拷贝到浏览器地址栏可以看到csv内容本身。那么为什么Firefox就根据CSP拦截了,而Chrome却没有拦截呢?

其实互联网的世界,并不是那么风平浪静。
以前在一篇文章里谈到过,计算机的世界其实并不是精确如一尘不染的实验室,而是建立在一系列没那么精确,不那么清晰,不是很靠谱的机制上。能跑就行。
虽然对于各种互联网协议的定义都有专门的组织去管理去发展。但是就如法律条文,协议与规范写的再精确也难免因为理解偏差而出现南辕北辙的情况。
而且从当年微软的IE带头不遵守规范伊始,各家浏览器的兼容性问题就始终困扰着广大开发者。就像你规定了IE,Firefox,Chrome都必须戴帽子出门,但是你会发现IE把帽子戴膝盖上了,Firefox斜着戴,Chrome反着戴——————谁让你没规定怎么戴。

这里关于CSP如何拦截我推测也是类似的情况(查阅浏览器代码实在力不从心),Chrome觉得blob: + 合法域名 = 合法,而Firefox觉得blob: 没有在名单里 = 不合法。

所以为了解决这个问题,我们只能干掉Firefox。我们只能绕开CSP,这是一种自己hack自己的行为,也许在某次Firefox更新或者salesforce更新之后就失效了。慎用。
如果说,VF所在的iFrame收到主页面的CSP策略约束,那么,我在iFrame中再造一个iFrame,外层iFrame没有设定CSP,在我造的iFrame里就可以为所欲为了。
代码如下

 let iframe = document.createElement('iframe');
 document.body.append(iframe);
 iframe.style.display = 'none';
 iframe.addEventListener('load', ()=> {
     let content = "test,test1\nabc,123";
     let blob = new Blob([content], { type: 'text/csv' });
     let csvUrl = URL.createObjectURL(blob);
     let elementLink = document.createElement('a');
     elementLink.href = csvUrl;
     elementLink.target = '_blank';
     elementLink.download = 'test.csv';
     elementLink.click();
 });

原理为,凭空捏造一个iFrame嵌到当前iFrame,然后监听load事件,将下载CSV的代码放入其中。这样当iFrame创建好了之后就会自动开始下载。

慎用。。。慎用。。。慎用

如何写出容易阅读的代码

一. 开篇废话

很多年前,我写过一篇文章叫《关于阅读代码 》。是在2014年写的,那时候刚开始写博客。根据后台统计数据显示,这篇文章这些年来也没什么人看。

其实文章分两种,一种是给自己看的,一种是给别人看的。就像写代码一样,有的代码你自己写的爽就行了,也没打算给别人看;还有一种,写出来的目的就是为了给别人看的。

所以回到《关于阅读代码》这篇文章,当时写的时候更多的是因为随着职业经历的积累自己有一些了感悟,觉得自己有一些绝妙的想法,想记录下来。这篇文章也一样。

作为程序员,工作职责中,可能写代码只占很小的一部分,不过因人而异。如果作为Salesfoce从业者,工作职责中80%以上都是写代码的话,我倒是觉得这其实是一件挺可悲的事情。应该考虑换一个平台或者试试别的角色。

但是,单就开发这部分来说,由于标准功能的存在,代码在Salesforce开发中的比重也不再如传统开发的100%。可以说我们的目标就是要干掉所有代码。

虽然说我们要干掉代码,但是代码却极其重要。毕竟所有标准功能无法实现的功能还是要代码去实现。这时候“对人友善”的代码在Salesforce开发活动中就格外的重要。毕竟在梳理业务流程的时候,谁也不想面对方便面一样的代码皱眉头。或者,被问到头上的时候,只能╮(╯▽╰)╭说:“这个功能好用但我不知道为什么。”

二. 不容易阅读的代码怎么来的

这些年我发现一个现象。习惯疯狂抽象分层的Java程序员,在转Salesforce开发之后往往更容易写出散文诗式流水账代码。然后再进行几次需求变动,代码就变成了方便面。

那么,为什么一个优秀的coder做salesforce之后会写出方便面代码?我觉得原因是接受到的信息的不同。

在Java时代,代码写手在动手之前接受到的信息为具体的功能模块,已经有架构师或同等角色的人将功能完全拆解或者绘制好蓝图。代码写手要做的是实现规划好的功能模块,将自己的模块成功嵌入到大框架内。而Salesforce代码写手接受的信息往往是业务描述——只是说了要做哪些事情,而不是严谨的功能模块划分。

举个例子,就像做意大利面,非Salesforce的厨师被提供了一个精确的食谱和严格的验收标准,要做的是用规定的食材严格按照食谱在指定地点把意大利面做好就行。而Salesforce的厨师只是被告知要做一盘什么味道的意大利面,可能连食材都还没买,到做好上桌之前都要自己搞定。

如果有自由发挥的空间,就像在饭店点蛋炒饭,每个厨师都会做出不同的味道。

继续阅读“如何写出容易阅读的代码”

Javascript版的String.format()

Salesforce的Apex语言作为Java的高度豪华定制版。不仅提供了很多Java原生就有的很好的方法与语法。同时也对Java进行了纷纷总总的简化。
比如Apex的String.format方法。
Apex的format方法提供了一个简单的方式可以对字符串模板进行关键字替换。
下面是官方文档的例子。

String template = '{0} was last updated {1}';
List<Object> parameters = new List<Object> {'Universal Containers', DateTime.newInstance(2018, 11, 15) };
String formatted = String.format(template, parameters);
System.debug ('Newly formatted string is:' + formatted);

如此一来,我们在custom label里做好带{x}字样的字符串模板,就可以随心所欲的动态生成内容了。

但是,随着进入Lightning时代,在Lightning Component前端承担的业务和内容越来越多,随之而来的就是出现了在前端直接获取字符串模板并动态生成内容的需求。
而Aura库和原生Javascript都没有提供类似与Apex的String.format方法。

俗话说,自己动手丰衣足食。那就自己写一个吧。
首先在Helper.js里面添加如下format方法。

    },    //.......... Other Helper Method
    format: function(label, args) {
        for (var k in args) {
            label = label.replace("{" + k + "}", args[k]);
        }
        return label;
    },
  // .......... Other Helper Method

然后在Controller.js中使用的时候,如同Apex版,第一个参数传入模板,第二个参数为按模板参数顺序传入的字符串数组。

let template = "{0} was last updated {1}";
let myLabel = helper.format(template , ["Universal Containers", "2018/11/15"]);

如何在使用e.force:createRecord创建数据之后阻止跳转到数据详细页面

有一天,小明同学和他的同伙们在实现一个需求。

三天前我路过他座位的时候简单的了解了下,感觉功能都实现的差不多了,怎么这会又开始一堆人聚一起叽叽喳喳的乱叫什么”CreateRecord事件”啦,什么“自定义Modal”啦,还让不让人静一静了。

“喂喂喂,怎么了怎么了?”。我实在忍不住上去扒拉开了最外面的几个。
被围在最里面的可怜人回头看到了我,立马趁机脱离开了包围圈。扯了扯衣角,开始介绍起情况来了。“是这样的,这个需求是我几天前给你介绍过的,就是一个Lightning Component上有一个按钮,然后点一下弹出一个新建数据的页面。”

“嗯嗯,然后呢”。我点了点头,心想着果然nothing new under sunshine,还是那个需求。
“然后我决定使用createRecord事件,就是e.force:createRecord。”,说着他扒拉开站在他身后的人,俯身用鼠标高亮了$A.get(“e.force:createRecord”)那行代码。“然后我们发现,居然在EditForm点保存之后,就直接跳到了新建数据的详细页面!”
在我开头之前,他立马切到了e.force:createRecord官方文档的页面,指在上面赶紧补充到,“我看了官方文档,上面也没有可以使用的参数或者选项。所以现在这几个人主张让我自己写一个modal出来,虽然这样一切都可控,但是工程量太大了。所以我们还在争论这件事。”

“主张做modal的先散了吧。”。接下来我做到了座位上,找出了相关的一个Idea 《Allow redirect after creating a new record using force:createRecord》。

小明难以置信的瞪大了眼睛,说:“这上面明明写的是没有这个功能啊。。。”
“上了这么多年的网,还不知道评论才是精华!?”

俗话说,文档上没写的不一定就没有。

——沃·兹几硕德

评论里提到了两个属性,一个是panelOnDestroyCallback,在Summer’19之后单独使用已经无效。另一个是navigationLocation有个固定写法”navigationLocation”:”LOOKUP”。

一起服用才有效果。

var createRecordEvent = $A.get("e.force:createRecord");
createRecordEvent.setParams({
	"entityApiName": "Opportunity",
	"navigationLocation": "LOOKUP",
	"panelOnDestroyCallback": function(event) {
		// Refesh current cmp
		$A.get('e.force:refreshView').fire();
		/* OR direct to anywhere
		let urlDirectEvent = $A.get("e.force:navigateToURL");
		urlDirectEvent.setParams({
			"url": "/lightning/n/XXXX"
		});
		urlDirectEvent.fire();
		*/
	}
});
createRecordEvent .fire();

但是,由于此属性从未出现在官方文档中,所以有可能就像Summer’19失效那次一样,在某一次升级之后又不好用了,使用还需慎重。

SFDX Create Project with Manifest模式下建议的.gitignore模板

俗话说,标题不长没人看。
不过这篇只是做一个存档,方便分享传阅,并没有故事。

如果使用VS Code选择了SFDX Create Project with Manifest,并且使用git进行代码版本管理,建议使用如下.gitignore

/*
!.gitignore
!anonymous.apex
!/force-app
!/manifest

此配置只追踪代码所在文件夹和package.xml所在文件夹。能有效的加快git处理速度。

// Update
解释:
Line 1 忽略此路径下所有文件夹,所属文件,及子文件夹及所属文件
Line 2 不忽略自己(.gitignore)
Line 3 VScode支持apex后缀的文件作为匿名块。所以你可以配和sfdx的命令愉快的储存匿名块重复使用了。公用的匿名块放在anonymous.apex同步到repo,私用的新建一个新的不同步到repo。
Line 4 不忽略代码所在文件夹及所属文件,及子文件夹与所属文件
Line 5 不忽略Package.xml所在文件夹及所属文件,及子文件夹与所属文件

Salesforce执行顺序小口诀

  1. 页面后台两不同,
  2. 布局规则最优先,
  3. 格式长度和必填。
  4. Before Trigger触发前,
  5. 后台只将外键检。
  6. 批量插入有例外,
  7. 验证规则提前验。
  8. Before之后做验证,
  9. 自定规则和必填,
  10. 系统规则不二遍。
  11. 验证之后跑去重,
  12. 存入DB不提交。
  13. After Trigger触发后,
  14. 分配/回复/工作流,
  15. 如果字段有更新,
  16. 验证/去重不再做,
  17. Trigger仅再跑一次。
  18. PB/Flow依次跑,
  19. 数据操作从头走,
  20. Case规则在随后。
  21. 父表汇总此时算,
  22. 工作流把父表更,
  23. 共享规则重计算。
  24. 数据DB提交后,
  25. 后续邮件才发送。

// 逐句解释更新中……
解释:
行1,行2,行3:与使用apex或者api新建或者更新数据相比, 通过Page Layout新建或者更新数据的行为会少许不同。通过Page Layout的时候会根据layout的字段属性配置提前进行一次字段格式(比如邮箱类型,电话号码格式等)/字段长度/字段是否必填等检查,才会进行后续的Trigger处理。
行4,行5:对于通过Apex或者API等方式后台创建/更新数据,虽然不会提前做一次系统级检查,但会对外键做一次检查,确保没有发生引用自己的情况。
行6,行7 :按理说接下来无论是用过Page Layout还是通过后台,下一步都应该进入Trigger处理了吧?还不一定。这里有一个例外形况,就是如果发生了例如Opportunity Line Item或者Quote Line Item的批量创建,则会提前执行一次自定义的Validation Rule。

官方文档地址: https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_triggers_order_of_execution.htm

利用Gitlab CI将代码部署至FTP服务器

作为Github的拥趸,最初这篇文章的标题是《利用Github Travis CI将代码部署至FTP服务器》。本来盘算的很好,利用这篇文章,一来是记录一下自己解决的问题,二来是宣传一下Github是多好用。
众所周知,Github等一众在线同性交友代码托管平台随着开发方法论的不断进化,已经演变成了CI/CD平台。哪家平台要是说自己不支持CI/CD,都不好意思出来打招呼。而Gitbub原来并没有自己的CI/CD功能,Travis CI充当了这一角色。最近,Github也推出了自己的Gitbub Actions来弥补这一块空缺,目前仍然在beta阶段。我也申请了测试资格,但目前仍然没有轮到我。

作为个人立场,很喜欢使用Github来托管代码。以前免费账号只能托管公开仓库,而收费账户又舍不得银子,无奈只能把私有仓库分散在Bitbucket和Gitlab上。
后来大家都知道了,Github被微软收购之后,免费账号也可以托管私有仓库了。我就琢磨着把所有repo都集中到Github上吧,结果猜怎么着?Travis CI免费版仍然只能用在公开仓库上。只好作罢。

当然,这次放弃Github+Travis CI组合的原因之中,Repo必须公开是其中一方面。另一方面是在使用Travis CI时遇到了无法解决的问题。

俗话说,先有问题才有的工具,而不是先有的工具而后产生的问题。首先说这次要解决的问题是什么。
其实这个问题很简单。平时在写文章或者写插件的过程中会遇到一些不懂的web技术,然后就需要做一些小demo或者简单的概念验证放到我自己的服务器上。由于有多设备切换开发的需求,任何代码都要放在远程git上做版本管理与同步。这样的话,就会出现一个稍许繁琐的操作————改完代码之后,要先commit到本地仓库,然后再push到远程仓库,然后将本地文件通过FTP工具上传到服务器,之后在浏览器打开网站进行测试,如果有问题则循环本套操作。

如果在我将代码提交到远程仓库之后,能够自动部署到FTP上该多好啊~!
很久很久以前,在SFDX还没发布的那个年代,我曾经利用GitHub+Travis CI做过一个Salesforce StandardSetController的小Demo的自动部署。每次提交完改动,就能自动部署到指定环境。我觉得部署到FTP怎么也得比部署到Salesforce Org还要简单吧!

说干就干,由于对Travis CI还算熟悉,立马查了一下文档,自信满满的写下了如下的yml文件。

# .travis.yml
language: node_js
script:
- echo "skipping tests"
after_success:
- curl --ftp-create-dirs
       -T index.html
       ftp://${FTPUSER}:${FTPPASSWORD}@${FTPENDPOINT}

语言无所谓,测试跳过,直接将文件通过ftp传送到服务器上。
结果执行之后,发现不对劲了。正常传送一个文件不说是瞬间完成,也不至于10分钟都传不完,导致被Travis强行把任务中止吧?然后去FTP上瞧了一眼,文件是创建出来了,但是大小是0KB。这肯定不对劲啊,查查吧。结果查到一篇Travis的官方博客,解释他们的NAT因为什么什么原因,导致FTP的被动模式失败之类的。总之就是ftp命令不能用了。
那行,我改用sftp命令。结果这次直接报错了,说不支持sftp命令。Execuse me? 然后搜了大半天,也没找到什么靠谱的解决方案。这就让人觉得不可思议了,对于CI工具来讲,FTP上传不比连接AWS、Heroku之类的简单多了?

正在感到弱小无助之际,我想起了一直在默默当备胎的Gitlab。
Gitlab也有自己的CI/CD工具,并且是免费,且可以用在私有Repo上的。
但是Gitlab的yml文件语法与Travis不同,只好硬着头皮速读了一下Gitlab CI的yml写法。
经过不断的研究与尝试,终于写下了如下的yml文件。(其实是从sample yml文件改造出来的)

# .gitlab-ci.yml
# This file is a template, and might need editing before it works on your project.
# Full project: https://gitlab.com/pages/plain-html
pages:
  stage: deploy
  script:
    - mkdir .public
    - cp -r * .public
    - mv .public public
    - apt-get update -qq && apt-get install -y -qq lftp
  artifacts:
    paths:
      - public
  only:
    - master
  after_script:
    - lftp -c "set ftp:ssl-allow no; open -u $USERNAME,$PASSWORD $HOST; mirror -Rev public/ ./ --ignore-time --parallel=10 --exclude-glob .git* --exclude .git/"

任务名称随意,artifact就是本次部署的全部文件归档,这里文件夹名字叫public。为了使用lftp命令,在脚本中提前安装lftp命令。
在CI/CD的setting页面提前配置好USERNAME, PASSWORD, HOST三个变量可以避免将敏感信息放到仓库文件中。
接下来在Gitlab仓库中启用CI/CD,就可以享受了。

其实同理,既然能用Gitlab CI部署代码到FTP,也能部署代码到Salesforce。之后还会写一篇Salesforce的兄弟篇。而这篇文章呢,也不想写CI/CD是什么,也不想谈论DevOps的优势,毕竟这种东西用关键字一搜索,满大街都是,感兴趣的人在看到文章头两个自然段之后早就自己去搜了。毕竟我说它千好万好,不如举一个实际的,好上手的例子让大家看到实实在在的东西来的实惠。

// 后记

也很长时间没有更新了。有人问我哪去了。其实。。。这期间发生了很多事情,也经历了很多事情。素材也是越攒越多。奇怪的是素材攒的越多越觉得没什么值得写的东西。可能,大家都在进步,对于Salesforce平台的基本认知已经都跨越了那道门槛,现在更需要的是Salesforce这款产品以外的,更关乎于计算机世界的、网络世界的基础知识,软件工程的基本概念。还有对于各个行业是如何运转的行业基本了解。

随着年龄增加,进入到了人生的不同阶段,会发现你完全属于你自己的时候越来越少。你逐渐被必须要承担的各种角色完全瓜分。以前争不到的东西现在更加的争不到,原来拥有的东西反而越丢越多。Whatever,要不然又能怎样呢,要不然选择随波逐流,要不然选择自己受的苦自己咽下去,你敢选第三条路吗?(摊手)

关于CANNOT_CASCADE_PRODUCT_ACTIVE错误

某日,一个悠闲的午后,我泡的一杯毛尖正在阳光下散发着热气。嗯,接到一个小活儿。平平稳稳,不需要经历大风大浪,不需过五关斩六将的小活儿——-导数据。

这次需要更新的数据是Product。就是那个标准SObject,Product2,三位prefix是01t。人们常说用标准SObject用多了总会遇见坑,但人生又何尝不是如此。不是
完全控制在自己手里的东西,只能去了解,去接受,最后习以为常。

果然,这次就掉坑里了。工具执行结束之后,一看Log。在1000条数据里面,有50多条报了如下的错误。
StatusCode: CANNOT_CASCADE_PRODUCT_ACTIVE
Error Message: Cannot update associated pricebook entries without the Manage Price Books permission
然后看了下报错的数据,也没什么特别明显的特征。 貌似是无差别报错。

唔。。。万事不决查文档。
很快就在《SOAP API Developer Guide》定位到了这个Status Code。
https://developer.salesforce.com/docs/atlas.en-us.api.meta/api/sforce_api_calls_concepts_core_data_objects.htm


CANNOT_CASCADE_PRODUCT_ACTIVE
An update to a product caused by a cascade can’t be done because the associated product is active.

《SOAP API Developer Guide》

咦?怎么详描述的与系统返回的信息不一样?


系统反馈给我的信息是“由于没有Manage Price Books Permission, 所以无法更新关联的Pricebook”。只是说由于我没有Manage Price Books权限才报错,但是我更新Product为什么又会在意我有没有Manage Price Books的权限?

而文档给的信息是“由于product是active,所以无法完成由联级更新引起的被关联的product。 ”这写的是啥啥啥?什么叫caused by a cascade,谁是发出动作的人?是我的话我又cascade了谁?谁又Associated了我?

既然《SOAP API Developer Guide》的描述完全不知所云,那么就从系统直接反馈的错误信息入手吧。查了一下使用的账号,Profile和Permission上确实都没有PriceBook的CRUD权限。不过。。。。如果是因为没有PriceBook的权限的话,为什么绝大部分数据还更新成功了?如果真是这样,由于我没有权限,应该一条都不会成功才对。

结合两条截然不同的信息来看。线索指向了两个关键点。
1. PriceBook的权限。
2. Product的active状态。

于是做了一个试验,在没有PriceBook编辑权限的前提下,尝试去变更Product的active状态。果然报出的相同的错误信息。
就是说,如果想更新product的active状态,需要Pricebook的编辑权限。
唔。。。。


故事当然没有到此结束,不甘心的我突发奇想的在本地硬盘搜索了一下这个status code,结果在一本上古文档里发现了这样一段描述。

CANNOT_CASCADE_PRODUCT_ACTIVE

You cannot activate or deactivate this product without also being able to edit pricebooks.

某上古文档

原来错误信息还有第三个版本。这个版本明确的指出了问题的所在。

接下来是无责任脑洞推理没有任何实锤和Evidence。

在Salesforce中,Product和Price Book是多对多的关系。一个product可以有多个Price Book,而Price Book也可以包含多个Product。Price Book Entry就是二者的Junction Object。
如果用workbench查看Price Book Entry上关联Product与Price Book的字段的关系属性,会发现该关系具有联级删除属性(cascadeDelete: true)。
所以在此推定Product与Price Book Entry,Price Book与Price Book Entry之间的关系皆为Master-Detail。
在表权限层级。

在Profile中,如果想要拥有子表的编辑权限,那么至少要拥有父表的View权限。反过来,如果连父表的view权限都没有,则子表没有任何权限。

所以,由于我没有Price Book的任何权限,导致失去Price Book Entry的所有权限。
而根据观察,Product在active状态变化的时候,会同步active状态到所有旗下的Product Book Entry。
在此推测Salesforce在Product上加了一个Trigger/Workflow/Flow/Process Builder用来用来同步active状态,而且在这个处理中特意做了权限检查。如果发现没有Price Book Entry的权限,则去检查Price Book的权限,如果也没有权限,就报错。

这也正是系统直接返回的错误信息的由来:

Cannot update associated pricebook entries without the Manage Price Books permission

至于为何错误信息出现这么多版本,我推测是由于与Product有多对多关系的不仅仅只有Price Book,所以同一个StatusCode从单一情景变为也会复用在其他的情景。所以错误信息在文档里就改成了更加泛指的版本。而这次我遇见的错误是一个很具体的场景,则出现的信息又由泛指的版本变为更加具体的版本。

脑洞完毕。我去加权限了。。

Dataloader与External Id

某日,又见某童鞋双手合十,指尖杵在下巴上,做福尔摩斯状。
忍不住凑了过去 。
此童鞋用眼角瞄到有人过来了,嘴角抽动了一下,欲言又止。

“别难为自己了,说说吧。”团结才是力量,对不对。
“好吧。。。”他长长的吐了一口气,手也放了下来。“事情是这样的,我有一张表叫做order。这个order有个字段叫product,会去lookup一个叫product的表。”。说着,他打开了SObject的详细页面。“现在我需要导入历史订单数据,量很大。”

我点了点头,示意他继续,因为目前为止听起来没什么。
“问题是原始数据的product字段并不是product的id,而是product的code。这样的话我没法直接导入系统。” 在我开口之前他赶紧抢先又说道,“product的种类和order的数据量都实在太大了,实在没办法用vlookup处理。已经死机好几次了。”

我弹开了他的手,接过鼠标打开了product表的定义页面。在自定义字段中找寻那个叫code的字段。唔。。。。External Id勾选了,Unique勾选了。不禁扬起了嘴角。

“看,Product的code是external Id。”我指着屏幕。
“可是我们要导入数据的是Order啊,不是Product。”
“那你先说说看,External Id在Dataloader里怎么用?”
“就是Upsert的时候,可以指定External Id,如果匹配上了就update,没匹配上就insert。”他自信的回答道。
“没错。但是还有一种用法。”
“啊?”

众所周知,lookup字段实际储存的值为ID。这也就意味着,在insert或者update数据的时候,我们要将Id写在lookup字段上。如果写的不是Id,或者Id不存在,Dataloader会告诉我们数据写入失败。
一般的情况下,如果我们只有被lookup的表中一个字段的值(Product Code),就不得不先根据这个值(Product的Code)去查出被lookup表的id(Product的Id),然后放在待更新数据的lookup字段上(Order的Product字段),才能继续执行下去(Order)。

不过,老天关上了你的门,一定会打开你的窗,毕竟你才是你生活的主角,所以有主角光环。

事实上,如果你拥有的值(Product Code)是被Lookup表的External Id(Product的Code),那么在插入数据时,可以让Dataloader根据External Id自动的将lookup字段填入对应的Id。这样就不用人工的去查找替换了。

做法如下。
1. 首先,一如往常,准备好csv,点insert,输入账号密码,选择Order,选择csv路径,下一步。
2. 然后,Create or Edit a Map, Auto-Match Fields to Columns。
3. 接下来就不一样了。在匹配完字段时候,点击Save Mapping。选择路径,填入文件名。保存sdl文件。
4. 找到刚刚保存的sdl文件,用文本编辑器打开。修改如下。保存。

#Mapping values
#Wed Apr 24 19:13:21 CST 2019
# Old 
#PRODUCT__C=product__c
# New 
PRODUCT__C=product__r\:code__c
.........

5. 回到Datalaoder,关掉Mapping Dialog窗口,这次点击Choose an Exsiting Map,选择刚刚保存的sdl文件。
6. 一路next。
7. 恭喜插入成功。(如果Product Code存在的话)

“看,你与成功之间只差一个\:”。我拍了拍了他的肩旁。
“恩人慢走”。

总结:
1. 被lookup表需要有External ID。
2. 在SDL文件中,将lookup字段的__c变成__r(标准字段去掉Id),加上“\:”,加上External Id的API Name。insert或者update的时候使用此sdl。
3. 必须仅且只仅匹配到一条记录才可以成功写入。

参考官方文档:
https://help.salesforce.com/articleView?id=000002783&type=1

后记:
有人问我说,唉,明明写的技术相关的内容,文风为什么这么不正经?
其实原因不外乎两个。第一个是想看正八经儿的说明文就应该去看官方文档,而不是等着官方文档的翻译。Salesforce的文档和知识库是很强大和全面的。第二个是每一篇文章的灵感来源都是真实被提出过的问题,我觉得自古以来,最好的承载知识的方式就是带入情景,讲故事。官方文档似的说明文是告诉你怎么一步一步的用锤子钉钉子,而我是想用故事告诉你可以用锤子来解决钉钉子的问题。
总之,想要一个工具速查手册的话,应该去看官方文档知识库才对。

解决OwnCloud的No input file specified.错误

临关机睡觉前,总觉得不升级点什么睡不踏实。比如手机应用,电脑操作系统,IDE和浏览器插件之类的,都要点一圈保证是最新的。
结果今天发现没有可以更新的。。。不行,难受。那就看看Godday的host的PHP有没有新版本吧。

由于租用时间较早,当年还是PHP5.2的天下。结果PHP7都快过时了,服务器可选择的PHP版本仍然停留在PHP5。俗话说,世界上最好的PHP版本是PHP6(并没有6,直接从5到7),我退而求其次,升级到7还不行?毕竟在性能上有着天差地别。

于是乎满怀期待的点开了Program Language Version。呦,提供了
新版本 PHP5.6。行吧,总比没有强。于是痛快的选择了这个稍微高一点点的版本。

很快升级就完成了。按流程,依次点开各个服务进行回归测试。博客,正常。论坛,正常…………网盘,“No input file specified.”。糟糕了。

网盘用的是老牌私有云盘OwnCloud,网上能搜出的信息不算太多。
于是乎冷静下来,分析目前的情况。
首先,变化的只有PHP的版本,而且不是大版本变动。服务本身没有变动。
其次,其他服务,比如Wordpress等皆运行正常。

所以,推测是OwnCloud的PHP设置问题。
由于Godaddy为Shared Host,所以并不能直接修改PHP设置,而是要通过.htcaccess文件去实现基于路径的调整。随之,我找到了网盘的根目录,打开了.htcaccess。
留意到了如下片段

<IfModule mod_rewrite.c>
  RewriteEngine on
  RewriteRule .* - [env=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
…………
</IfModule>

咦?我怎么记得RewriteEngine下面应该有个RewriteBase啊。
这小问题,于是顺手加上了“RewriteBase /”。保存。

<IfModule mod_rewrite.c>
  RewriteEngine on
  RewriteBase /
  RewriteRule .* - [env=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
…………
</IfModule>

刷新之后,还是 “No input file specified.” 。哎呀?Wordpress的配置文件就是这么写的啊。然后又开始疯狂搜索信息,发现了很多在Godaddy上部署了Owncloud的受害者遇到过相同的问题,但是都没有找到答案。

看来还是得靠自己。经过一番努力地思考,难道因为是SharedHost,所以必须用绝对路径?而且由于当年不会给子域名加SSL,所以作为早期架起的服务,Owncloud并没有分配子域名,所以不该写根目录?
于是根据Goddady的Control Panel提供的绝对路径地址,拼上Owncloud的所在路径,写在了RewriteBase中。

<IfModule mod_rewrite.c>
  RewriteEngine on
  RewriteBase /XXXX/XXXX/XXX/99999999/html/cloud/owncloud5/
  RewriteRule .* - [env=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
…………
</IfModule>

再次访问OwnCloud的URL,恢复正常。

总结,
1. RewriteEngine后面要接RewriteBase
2. 如果不是域名的根目录,要写全路径。
3. 如果相对路径不好用,尝试绝对路径。

PHP7什么时候才提供啊。。。GoDaddy!