7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SalesforceAdvent Calendar 2022

Day 6

Salesforce Apex Selector Patternについて

Last updated at Posted at 2022-12-07

この記事は Salesforceのカレンダー | Advent Calendar 2022 - Qiita 第6日目の投稿です。

みなさん、SalesforceのSelector パターンってご存知ですか?

とある日の平日。
リファクタリングのために私はソースコードを眺めながら上司と相談していました。
『この部分はSelectorパターンっぽい感じに書くようにリファクタリングするのも良いのかもしれない』と言われました。

…Selectorパターン?デザインパターンの一つなんだろうと察することはできましたが、
GoFデザインパターンに出てくるデザインパターンに、そんなのあったっけな?とか考えましたが、いかんせん分からない。

軽く説明してもらい、なんとなくDAOみたいだなとか、わかった気になりましたが、良い機会なので、Salesforceにおけるデザインパターンについて勉強しようと思い立ち、今回のお題にすることにしました。

で、結局、Selector Patternって何なの?

Apexにおけるデザインパターンの一つです。
いろんな参考文献を私なりに読み漁り流し見した結果、ひとことで言うとしたら、「Selectorクラスを作ってSOQLクエリを書くロジックを1箇所にまとめて、保守性を高めましょう」という感じで理解しました。

(画像引用:セレクタレイヤの原則について 単元 | Salesforce Trailheadより)
image.png

Apexデザインパターンについて、Web上から追跡できるレベルで歴史を振り返ってみたところ、 2012年のDreamforceにて、 Apex Enterprise Patternと言う名前で公開されプレゼンテーションされたものらしいですね。
(判断元: https://github.com/financialforcedev/df12-apex-enterprise-patterns )
そこから派生して今現在、FFLib Apex Commonが公開されています。
(判断元: https://github.com/apex-enterprise-patterns/fflib-apex-common)

一番大事なことは関心の分離Service LayerDomain LayerSelector Layerに分離して、実装していきましょうと言う事らしいです。
(参考文献: https://github.com/financialforcedev/df12-apex-enterprise-patterns)

2012年に公開された[df12-apex-enterprise-patterns]のSelector Patternを読んで思った事をつらつらと・・・(読み飛ばしていい内容なので折りたたみ)

2012年に公開されたdf12-apex-enterprise-patternsのSelector Patternを読んで思った事をつらつらと・・・

今回、参考にしたソースはDreamforce 2012でプレゼンテーションされたData Mapper (Selector)です。
具体的にはREADMEで言及している以下3点のソースコードを読んでみました。

See OpportunitiesSelector.cls, OpportunityLineItemsSelector.cls and SObjectSelector

image.png

// df12-apex-enterprise-patterns/blob/master/df12/src/classes/SObjectSelector.clsより抜粋
	public List<SObject> selectSObjectsById(Set<Id> idSet)
	{
		assertIsAccessible();
		return Database.query(String.format('SELECT {0} FROM {1} WHERE id in :idSet ORDER BY {2}', new List<String>{getFieldListString(),getSObjectName(),getOrderBy()}));
	}

上記のように親クラスであるSObjectSelectorで、事前に子クラスで定義した項目リストを持ってきて、Database.queryを実行することで、データ取得する仕組みになってますね。

// df12-apex-enterprise-patterns/blob/master/df12/src/classes/SObjectSelector.clsより抜粋
	public void assertIsAccessible()
	{
    	if(!getSObjectType().getDescribe().isAccessible())
    	   throw new SObjectDomain.DomainException('Permission to access an ' + getSObjectType().getDescribe().getName() + ' dennied.');		
	}

Database.query実行前にassertIsAccessible()を呼び出して、CRUDを意識している作り込みでした。

親クラスSObjectSelectorを継承した子クラスOpportunitiesSelectorでは、getSObjectFieldList()内でSELECTに必要な基本項目を定義しています。

// df12-apex-enterprise-patterns/blob/master/df12/src/classes/OpportunitiesSelector.clsより抜粋
	public List<Schema.SObjectField> getSObjectFieldList()
	{
		return new List<Schema.SObjectField> {
			Opportunity.AccountId,
			Opportunity.Amount,
			Opportunity.CloseDate,
			Opportunity.Description,
			Opportunity.ExpectedRevenue,
			Opportunity.Id,
			Opportunity.Name,
			Opportunity.Pricebook2Id,
			Opportunity.Probability,
			Opportunity.StageName,
			Opportunity.Type,
			Opportunity.DiscountType__c
		};
	}

Idを起点に取得したい場合は親クラスのselectSObjectsByIdを呼び出すだけです。

// df12-apex-enterprise-patterns/blob/master/df12/src/classes/OpportunitiesSelector.clsより抜粋
	public List<Opportunity> selectById(Set<ID> idSet)
	{
		return (List<Opportunity>) selectSObjectsById(idSet);
	}

拡張の例としてselectByIdWithProductsメソッドの記載ありますね。

// df12-apex-enterprise-patterns/blob/master/df12/src/classes/OpportunitiesSelector.clsより抜粋
	public List<Opportunity> selectByIdWithProducts(Set<ID> idSet)
	{
		assertIsAccessible();

		OpportunityLineItemsSelector opportunityLineItemSelector = new OpportunityLineItemsSelector(); 
		PricebookEntriesSelector pricebookEntrySelector = new PricebookEntriesSelector(); 
		ProductsSelector productSelector = new ProductsSelector(); 
		PricebooksSelector pricebookSelector = new PricebooksSelector(); 
		
		opportunityLineItemSelector.assertIsAccessible();
		pricebookEntrySelector.assertIsAccessible();
		productSelector.assertIsAccessible();
		pricebookSelector.assertIsAccessible();
		
		String query = String.format(
				'select {0}, ' +
				  '(select {3},{5},{6},{7} ' +
				     'from OpportunityLineItems ' + 
				     'order by {4}) ' + 
				  'from {1} ' +
				  'where id in :idSet ' + 
				  'order by {2}', 
			new List<String>{
				getFieldListString(),
				getSObjectName(),
				getOrderBy(),
				opportunityLineItemSelector.getFieldListString(),
				opportunityLineItemSelector.getOrderBy(),
				pricebookEntrySelector.getRelatedFieldListString('PricebookEntry'),
				productSelector.getRelatedFieldListString('PricebookEntry.Product2'),
				pricebookSelector.getRelatedFieldListString('PricebookEntry.Pricebook2')
			});
		
		return (List<Opportunity>) Database.query(query);
	}

改めて見ると、セレクタパターンというものが、どういうものか少し分かってきますね。
(DBアクセス部分のみをまとめたんだなって)

しかし、こちらは2012年とかなり古い時代に書かれているためなのでしょうか。APIバージョンも24.0であり、公式が出している開発者ドキュメント「Apex Developer Guide」にも情報が乗っていない(サポート切れた)時代の代物でした。

今はFLSチェックも必須の時代なので、少し物足りない感じがあります。

FFLib Apex CommonのSelector Patternを読んで思った事をつらつらと・・・

Trailheadには、Apex エンタープライズパターンという名前のモジュールが存在します。そこからリンクを辿る事で、Github上に、FFLib Apex Commonや、FFLib Apex Common Sampleなどのコードが公開されていました。
こちらは今も定期的に更新されている模様です。

一部抜粋ですが商談のデータを取得する仕組みを作る方法として以下のような仕組みとなっていました。他にも色々とコアクラスのようなものを参照していましたが、特に重要そうな部分のみを抜粋しています。

image.png

フォルダ分け目を引きますね。サンプル側のコードではclassesフォルダの下に、selectorsフォルダ、serviceフォルダ、batchjobsフォルダを作っていました、参考にしても良いのかもなと思いました。

また、すべての親クラスとなるfflib_SObjectSelectorのコンストラクタでCRUDセキュリティチェックとFLSセキュリティチェックが必要な場合に合わせて設定できるようになっていました。

// fflib-apex-common/blob/master/sfdx-source/apex-common/main/classes/fflib_SObjectSelector.clsより抜粋
    /**
     * Constructs the Selector
     *
     * @param includeFieldSetFields Set to true if the Selector queries are to include Fieldset fields as well 
     **/
    public fflib_SObjectSelector(Boolean includeFieldSetFields)
    {
        this(includeFieldSetFields, true, false);
    }
    
    /**
     * Constructs the Selector
     *
     * @param includeFieldSetFields Set to true if the Selector queries are to include Fieldset fields as well 
     **/
    public fflib_SObjectSelector(Boolean includeFieldSetFields, Boolean enforceCRUD, Boolean enforceFLS)
    {
        this(includeFieldSetFields, enforceCRUD, enforceFLS, true);
    }
   /**
     * Constructs the Selector
     *
     * @param includeFieldSetFields Set to true if the Selector queries are to include Fieldset fields as well 
     * @param enforceCRUD Enforce CRUD security
     * @param enforceFLS Enforce Field Level Security
     * @param sortSelectFields Set to false if selecting many columns to skip sorting select fields and improve performance
     **/
    public fflib_SObjectSelector(Boolean includeFieldSetFields, Boolean enforceCRUD, Boolean enforceFLS, Boolean sortSelectFields)
    {
        m_includeFieldSetFields = includeFieldSetFields;
        m_enforceCRUD = enforceCRUD;
        m_enforceFLS = enforceFLS;
        m_sortSelectFields = sortSelectFields;
    }


fflib_SecurityUtilsに定義されたCRUDやFLSのセキュリティチェックロジックの呼び出しは、fflib_SObjectSelectorではなく、fflib_QueryFactoryにてSOQL作成している時に呼び出しているようですね。(以下、newQueryFactoryのコード部分を追うとfflib_QueryFactoryが呼び出されてます)

// fflib-apex-common/blob/master/sfdx-source/apex-common/main/classes/fflib_SObjectSelector.clsより抜粋
    /**
     * Performs a SOQL query, 
     *   - Selecting the fields described via getSObjectFieldsList and getSObjectFieldSetList (if included) 
     *   - From the SObject described by getSObjectType
     *   - Where the Id's match those provided in the set
     *   - Ordered by the fields returned via getOrderBy
     * @returns A list of SObject's
     **/
    public virtual List<SObject> selectSObjectsById(Set<Id> idSet)
    {
        return Database.query(buildQuerySObjectById());
    }
        
    /**
     * Performs a SOQL query, 
     *   - Selecting the fields described via getSObjectFieldsList and getSObjectFieldSetList (if included) 
     *   - From the SObject described by getSObjectType
     *   - Where the Id's match those provided in the set
     *   - Ordered by the fields returned via getOrderBy
     * @returns A QueryLocator (typically for use in a Batch Apex job)
     **/
    public virtual Database.QueryLocator queryLocatorById(Set<Id> idSet)
    {
        return Database.getQueryLocator(buildQuerySObjectById());
    }

    /**
     * Constructs the default SOQL query for this selector, see selectSObjectsById and queryLocatorById
     **/    
    protected String buildQuerySObjectById()
    {   
        return newQueryFactory().setCondition('id in :idSet').toSOQL();
    }
    	

元々2012年の頃に発表されたdf12-apex-enterprise-patterns/blob/master/df12/src/classes/SObjectSelector.clsでは、子クラスで定義した項目を活用して、「CRUDセキュリティチェック」と「SOQL実行結果を返す」責務を持っておりました。

今回のfflib-apex-common/blob/master/sfdx-source/apex-common/main/classes/fflib_SObjectSelector.clsでは、子クラスで定義した項目を活用して、「CRUDセキュリティチェック」、「FLSセキュリティチェック」、「SOQL実行結果を返す」、「バッチ処理用にDatabase.QueryLocatorを返す」責務を持つように変わっています。

fflib_SObjectSelectorの継承先の子クラスOpportunitiesSelectorでは、以下のように利用する項目を定義する部分は2012年の頃と変わりありません。

// fflib-apex-common-samplecode/blob/master/sfdx-source/apex-common-samplecode/main/classes/selectors/OpportunitiesSelector.clsより抜粋
	public List<Schema.SObjectField> getSObjectFieldList()
	{
		return new List<Schema.SObjectField> {
			Opportunity.AccountId,
			Opportunity.Amount,
			Opportunity.CloseDate,
			Opportunity.Description,
			Opportunity.ExpectedRevenue,
			Opportunity.Id,
			Opportunity.Name,
			Opportunity.Pricebook2Id,
			Opportunity.Probability,
			Opportunity.StageName,
			Opportunity.Type,
			Opportunity.DiscountType__c
		};
	}

また、以下のように親クラスfflib_SObjectSelectorselectSObjectsById機能をそのまま利用して、SOQL実行結果を取得する部分も2012年の頃と変わりありません。そのため、2012年に発表されたApex Enterprise Patternsを使っていた人にはあまり変わらず使いやすい仕組みになってました。

// fflib-apex-common-samplecode/blob/master/sfdx-source/apex-common-samplecode/main/classes/selectors/OpportunitiesSelector.clsより抜粋
	public List<Opportunity> selectById(Set<Id> idSet)
	{
		return (List<Opportunity>) selectSObjectsById(idSet);
	}

拡張性は以下の通りです。
2012年のソースと比較して大きな変更点としては拡張メソッド内でSELECT文を構築する必要がなくなったことですね。

// fflib-apex-common-samplecode/blob/master/sfdx-source/apex-common-samplecode/main/classes/selectors/OpportunitiesSelector.clsより抜粋
	public List<Opportunity> selectByIdWithProducts(Set<Id> idSet)
	{
		fflib_QueryFactory opportunitiesQueryFactory = newQueryFactory();

		fflib_QueryFactory lineItemsQueryFactory = 
			new OpportunityLineItemsSelector().
				addQueryFactorySubselect(opportunitiesQueryFactory);
			
		new PricebookEntriesSelector().
			configureQueryFactoryFields(lineItemsQueryFactory, 'PricebookEntry');
		new ProductsSelector().
			configureQueryFactoryFields(lineItemsQueryFactory, 'PricebookEntry.Product2');
		new PricebooksSelector().
			configureQueryFactoryFields(lineItemsQueryFactory, 'PricebookEntry.Pricebook2');

		return (List<Opportunity>) Database.query(
			opportunitiesQueryFactory.setCondition('id in :idSet').toSOQL());
	}
	
	public List<OpportunityInfo> selectOpportunityInfo(Set<Id> idSet)
	{
		List<OpportunityInfo> opportunityInfos = new List<OpportunityInfo>();

		for(Opportunity opportunity : Database.query( newQueryFactory(false)
														.selectField(Opportunity.Id)
														.selectField(Opportunity.StageName)
														.selectField(Opportunity.Amount)
														.selectField('Account.Name')
														.selectField('Account.AccountNumber')
														.selectField('Account.Owner.Name')
														.setCondition('id in :idSet')
														.toSOQL() ))
		{
			opportunityInfos.add(new OpportunityInfo(opportunity));
		}
			
		return opportunityInfos;	
	}
	
	public Database.QueryLocator queryLocatorReadyToInvoice()
	{
		return Database.getQueryLocator(
			newQueryFactory().setCondition(Opportunity.InvoicedStatus__c + ' = \'Ready\'').toSOQL());
	}

正直、よく出来ているなと感心しました。

ただ、FFLib Apex Commonを取り込むとなると関係する大量のソースコードを取り込む羽目になります。
そのことからTrailheadのモジュール「セレクタレイヤの原則について」に記載されている通り、「大規模で複雑なエンタープライズレベル」向けなんだろうなと感じました。

実際に導入することで、CRUDやFLSのセキュリティチェックを担当者に考えさせる必要なく機能拡張できるようになります。

しかし、SOQL インジェクション対策の目線でみると、SOQL クエリを動的に作成している部分が気になります。項目はFLSセキュリティチェックを通るので気にしなくて良いと思います。
ただ、WHERE句となる部分fflib_QueryFactoryクラスのsetCondition内では何も制御していません。
setConditionの引数例としてnewQueryFactory().setCondition('id in :idSet')と記載があるため、バインド変数を意識した作り込みはできますが、担当者の善意に委ねられてるようにも見えます。
そのため、fflib-apex-commonを取り込み利用するとしたらsetConditionにセットする引数は意識してあげる必要があります。

また、fflib-apex-commonはSELECT句には手厚いですが、DML系(insert, update, delete)には手厚くありません。

よって、これを参考にDML系も含めてセレクタレイヤの事を考えてあげて良いのかもしれないなと思いました。

その他:セキュリティ絡みの補足

セレクタパターンとは少しずれますが、静的クエリであるSOQL ステートメントに対して WITH SECURITY_ENFORCED を使ったり、stripInaccessibleを使ったり、今はまだベータ版ですがWITH USER_MODEWITH SYSTEM_MODE を使ってCRUDやFLSセキュリティチェック処理を実装する方法が提供されてます。

プロジェクトの規模によっては、これらを活用して(セレクタパターン・・・というとSELECT句ぐらいしか対応しないのかと誤解したくなるので、元の名前から拝借して)Data Mapper層を作っても良いのかもしれないと感じました。

参考文献

  • Filter SOQL Queries Using WITH SECURITY_ENFORCEDは、Spring '19 API version 45.0にBeta版として機能が追加され、Spring '20 API version 48.0 正式リリースされました。オブジェクト/項目に参照権限がない場合、QueryExceptionを投げる仕組みです。DMLには利用できません。

  • Enforce Security With the stripInaccessible Methodは、Summer '19 (API version 46.0)にPilot版が出て、Winter '20 (API version 47.0)にBate版、Spring '20 (API version 48.0)に正式リリースされました。オブジェクトに参照権限がない場合NoAccessExceptionを投げ、項目に参照権限がない場合は権限のない項目を除外してレコードを取得してくれる仕組みです。insertやupdateなどDMLにも利用できます仕組みです。

  • Enforce User Mode for Database Operations (Beta)は、Summer '22 API version 55.0 にBeta版として機能が追加されました。ちかいうちに正式リリースされるはずですが使えます。WITH SECURITY_ENFORCEDの上位互換のような存在であり、WITH USER_MODEWITH SYSTEM_MODEと状況に応じて分けて指定でき、insertやupdateなどDMLにも利用できます仕組みです。使い方の例はサイト「 Secure Apex Code with User Mode Database Operations」の内容が参考になります。

終わりに

最後に、今回はアドベンドカレンダーのネタにすごく悩みつつ、しかし(今年も何かアドベントカレンダーに投稿するぜ!)ってモチベーションで身近な出来事をもとに勉強する場を無理やり作ってみました。
今は2022年ですが、今の今まで公開されているApexのデザインパターンのコードをじっくりみる機会がなかったので、良い機会に恵まれたと思います。

参考文献

今回の記事を書くにあたって、以下のサイト達を参考にさせていただきました。ありがとうございました。

以上です。
ではでは!

7
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?