├── .gitignore ├── src ├── classes │ ├── Condition.cls │ ├── Operand.cls │ ├── InvalidQueryException.cls │ ├── QueryElement.cls │ ├── Condition.cls-meta.xml │ ├── Field.cls-meta.xml │ ├── Metadata.cls-meta.xml │ ├── Operand.cls-meta.xml │ ├── Query.cls-meta.xml │ ├── QueryTest.cls-meta.xml │ ├── SortInfo.cls-meta.xml │ ├── OperandTypes.cls-meta.xml │ ├── QueryElement.cls-meta.xml │ ├── CompositeCondition.cls-meta.xml │ ├── ConditionBuilder.cls-meta.xml │ ├── MetadataValidator.cls-meta.xml │ ├── NegateCondition.cls-meta.xml │ ├── SObjectDescription.cls-meta.xml │ ├── SingleCondition.cls-meta.xml │ ├── InvalidQueryException.cls-meta.xml │ ├── SObjectFieldDescription.cls-meta.xml │ ├── SObjectDescription.cls │ ├── ConditionBuilder.cls │ ├── NegateCondition.cls │ ├── SingleCondition.cls │ ├── SortInfo.cls │ ├── Field.cls │ ├── CompositeCondition.cls │ ├── MetadataValidator.cls │ ├── Query.cls │ ├── SObjectFieldDescription.cls │ ├── QueryTest.cls │ ├── OperandTypes.cls │ └── Metadata.cls └── package.xml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | config/* 2 | debug/* 3 | 4 | -------------------------------------------------------------------------------- /src/classes/Condition.cls: -------------------------------------------------------------------------------- 1 | public interface Condition extends QueryElement { 2 | } -------------------------------------------------------------------------------- /src/classes/Operand.cls: -------------------------------------------------------------------------------- 1 | public interface Operand extends QueryElement { 2 | void validate(); 3 | } -------------------------------------------------------------------------------- /src/classes/InvalidQueryException.cls: -------------------------------------------------------------------------------- 1 | public with sharing class InvalidQueryException extends Exception {} 2 | -------------------------------------------------------------------------------- /src/classes/QueryElement.cls: -------------------------------------------------------------------------------- 1 | public interface QueryElement { 2 | String toSOQL(); 3 | void validate(); 4 | } -------------------------------------------------------------------------------- /src/classes/Condition.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/Field.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/Metadata.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 32.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/Operand.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/Query.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/QueryTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/SortInfo.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/OperandTypes.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/QueryElement.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/CompositeCondition.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/ConditionBuilder.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/MetadataValidator.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/NegateCondition.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/SObjectDescription.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 32.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/SingleCondition.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/InvalidQueryException.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 33.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/SObjectFieldDescription.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 32.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/SObjectDescription.cls: -------------------------------------------------------------------------------- 1 | public with sharing class SObjectDescription { 2 | 3 | @AuraEnabled 4 | public String name { get; set; } 5 | @AuraEnabled 6 | public String label { get; set; } 7 | @AuraEnabled 8 | public SObjectFieldDescription[] fields { get; set; } 9 | 10 | public SObjectDescription(String name, String label) { 11 | this.name = name; 12 | this.label = label; 13 | } 14 | 15 | public SObjectDescription(String name, String label, SObjectFieldDescription[] fields) { 16 | this.name = name; 17 | this.label = label; 18 | this.fields = fields; 19 | } 20 | 21 | } -------------------------------------------------------------------------------- /src/classes/ConditionBuilder.cls: -------------------------------------------------------------------------------- 1 | public with sharing class ConditionBuilder { 2 | public static Condition buildCondition(Map config, String baseObjectName, Boolean stripParens) { 3 | if (config.get('field') != null) { 4 | return new SingleCondition(config, baseObjectName); 5 | } else { 6 | String operator = (String) config.get('operator'); 7 | if (operator != null && operator.toUpperCase() == 'NOT') { 8 | return new NegateCondition(config, baseObjectName, stripParens); 9 | } else { 10 | return new CompositeCondition(config, baseObjectName, stripParens); 11 | } 12 | } 13 | return null; 14 | } 15 | } -------------------------------------------------------------------------------- /src/package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | CompositeCondition 5 | Condition 6 | ConditionBuilder 7 | Field 8 | InvalidQueryException 9 | Metadata 10 | MetadataValidator 11 | NegateCondition 12 | Operand 13 | OperandTypes 14 | Query 15 | QueryElement 16 | QueryTest 17 | SObjectDescription 18 | SObjectFieldDescription 19 | SingleCondition 20 | SortInfo 21 | ApexClass 22 | 23 | 34.0 24 | 25 | -------------------------------------------------------------------------------- /src/classes/NegateCondition.cls: -------------------------------------------------------------------------------- 1 | public with sharing class NegateCondition implements Condition { 2 | public String operator { get; set; } 3 | public Condition condition { get; set; } 4 | public Boolean stripParens { get; set; } 5 | 6 | public NegateCondition(Map config, String baseObjectName, Boolean stripParens) { 7 | this.operator = (String) config.get('operator'); 8 | Map condition = (Map) config.get('condition'); 9 | if (condition != null) { 10 | this.condition = ConditionBuilder.buildCondition(condition, baseObjectName, true); 11 | } 12 | this.stripParens = stripParens; 13 | } 14 | 15 | public void validate() { 16 | if (this.condition == null) { 17 | throw new InvalidQueryException('Negating condition is not defined'); 18 | } 19 | this.condition.validate(); 20 | } 21 | 22 | public String toSOQL() { 23 | String condition = 'NOT ' + this.condition.toSOQL(); 24 | return !this.stripParens ? '(' + condition + ')' : condition; 25 | } 26 | } -------------------------------------------------------------------------------- /src/classes/SingleCondition.cls: -------------------------------------------------------------------------------- 1 | public with sharing class SingleCondition implements Condition { 2 | public static Pattern OPERATOR_PATTERN = Pattern.compile('(?i)^(=|!=|>|>=|<|<=|LIKE|IN|NOT IN)$'); 3 | public String operator { get; set; } 4 | public Field field { get; set; } 5 | public Operand value { get; set; } 6 | 7 | public SingleCondition(Map config, String baseObjectName) { 8 | this.operator = (String) config.get('operator'); 9 | this.field = new Field((String) config.get('field'), baseObjectName); 10 | this.value = OperandTypes.createOperand(config.get('value')); 11 | } 12 | 13 | public void validate() { 14 | this.field.validate(); 15 | if (this.operator == null || !OPERATOR_PATTERN.matcher(this.operator).matches()) { 16 | throw new InvalidQueryException('Invalid condition operator : ' + this.operator); 17 | } 18 | if (this.value == null) { 19 | throw new InvalidQueryException('Condition operand is not available'); 20 | } else { 21 | this.value.validate(); 22 | } 23 | } 24 | 25 | public String toSOQL() { 26 | return field.toSOQL() + ' ' + operator + ' ' + value.toSOQL(); 27 | } 28 | 29 | } -------------------------------------------------------------------------------- /src/classes/SortInfo.cls: -------------------------------------------------------------------------------- 1 | public with sharing class SortInfo implements QueryElement { 2 | public Field field { get; set; } 3 | public String direction { get; set; } 4 | public String nullOrder { get; set; } 5 | 6 | public SortInfo(Map config, String baseObjectName) { 7 | this.field = new Field((String) config.get('field'), baseObjectName); 8 | this.direction = (String) config.get('direction'); 9 | this.nullOrder = (String) config.get('nullOrder'); 10 | } 11 | 12 | public void validate() { 13 | this.field.validate(); 14 | if (this.direction == null || 15 | (this.direction.toUpperCase() != 'ASC' && this.direction.toUpperCase() != 'DESC') ) { 16 | throw new InvalidQueryException('Sort direction is not valid : ' + this.direction); 17 | } 18 | if (this.nullOrder != null && 19 | this.nullOrder.toUpperCase() != 'LAST' && this.nullOrder.toUpperCase() != 'FIRST') { 20 | throw new InvalidQueryException('Sort null order is invalid : ' + this.nullOrder); 21 | } 22 | } 23 | 24 | public String toSOQL() { 25 | String str = this.field.toSOQL() + ' ' + this.direction.toUpperCase(); 26 | if (this.nullOrder != null) { 27 | str += ' NULLS ' + this.nullOrder.toUpperCase(); 28 | } 29 | return str; 30 | } 31 | } -------------------------------------------------------------------------------- /src/classes/Field.cls: -------------------------------------------------------------------------------- 1 | public with sharing class Field implements QueryElement { 2 | public String[] path { get; set; } 3 | public String baseObjectName { get; set; } 4 | 5 | public Field(String path, String baseObjectName) { 6 | this.path = path.split('\\.'); 7 | this.baseObjectName = baseObjectName; 8 | } 9 | 10 | public void validate() { 11 | System.debug('Validating field: ' + this.path); 12 | String sobjName = this.baseObjectName; 13 | Integer len = this.path.size(); 14 | for (Integer i = 0; i < len; i++) { 15 | String refName = this.path[i]; 16 | System.debug('Check if sobject ' + sobjName + '.' + refName + ' is accessible field'); 17 | if (i == len - 1) { 18 | if (!MetadataValidator.isFieldAccessible(refName, sobjName)) { 19 | throw new InvalidQueryException('Field is not available or not accessible: ' + sobjName + '.' + refName); 20 | } 21 | } else { 22 | String[] parents = MetadataValidator.getParentRelationshipObjects(refName, sobjName); 23 | for (String parent: parents) { 24 | System.debug('parent = ' + parent); 25 | } 26 | if (parents == null) { 27 | throw new InvalidQueryException('Parent relationship is not available or not accessible: ' + sobjName + '.' + refName); 28 | } else { 29 | sobjName = parents.size() == 1 ? parents[0] : 'Name'; 30 | } 31 | } 32 | } 33 | } 34 | 35 | public String toSOQL() { 36 | return String.join(this.path, '.'); 37 | } 38 | } -------------------------------------------------------------------------------- /src/classes/CompositeCondition.cls: -------------------------------------------------------------------------------- 1 | public with sharing class CompositeCondition implements Condition { 2 | public static Pattern OPERATOR_PATTERN = Pattern.compile('(?i)^(AND|OR)$'); 3 | public String operator { get; set; } 4 | public Condition[] conditions { get; set; } 5 | public Boolean stripParens { get; set; } 6 | 7 | public CompositeCondition(Map config, String baseObjectName, Boolean stripParens) { 8 | this.operator = (String) config.get('operator'); 9 | Object[] conditions = (Object[]) config.get('conditions'); 10 | if (conditions != null) { 11 | this.conditions = new Condition[] {}; 12 | for (Object c : conditions) { 13 | Map cond = (Map) c; 14 | this.conditions.add(ConditionBuilder.buildCondition(cond, baseObjectName, false)); 15 | } 16 | } 17 | this.stripParens = stripParens; 18 | } 19 | 20 | public void validate() { 21 | System.debug('Validating Composite Condition: ' + this.operator); 22 | if (this.operator == null || !OPERATOR_PATTERN.matcher(this.operator).matches()) { 23 | throw new InvalidQueryException('Invalid logical operator : ' + this.operator); 24 | } 25 | if (this.conditions == null || this.conditions.size() == 0) { 26 | throw new InvalidQueryException('Composite conditions definitions are not found'); 27 | } 28 | for (Condition condition : this.conditions) { 29 | condition.validate(); 30 | } 31 | } 32 | 33 | public String toSOQL() { 34 | String[] conditionStrings = new String[] {}; 35 | for (Condition cond : this.conditions) { 36 | conditionStrings.add(cond.toSOQL()); 37 | } 38 | String condition = String.join(conditionStrings, ' ' + this.operator.toUpperCase() + ' '); 39 | return conditionStrings.size() > 0 && !this.stripParens ? '(' + condition + ')' : condition; 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # soql-secure 2 | A library to build/execute SOQL from JSON definition in Apex with secure FLS check 3 | 4 | ## Usage 5 | 6 | #### Server(Apex) 7 | 8 | ```java 9 | public with sharing class MyRemoteController { 10 | @RemoteAction 11 | public static List query(String queryJSON) { 12 | Map qconfig = (Map) JSON.deserializeUntyped(queryJSON); 13 | Query query = new Query(qconfig); 14 | query.validate(); // HERE we check FLS and other access control 15 | return Database.query(query.toSOQL()); 16 | } 17 | } 18 | ``` 19 | 20 | ### Client (JavaScript in Visualforce) 21 | 22 | ```javascript 23 | // Define query in JSON 24 | var queryConfig = { 25 | "fields": [ "Id", "Name", "Account.Name" ], 26 | "table": "Contact", 27 | "condition": { 28 | "operator": "AND", 29 | "conditions": [{ 30 | "field": "CloseDate", 31 | "operator": "=", 32 | "value": { "type": "date", "value": "THIS_MONTH" } 33 | }, { 34 | "field": "Amount", 35 | "operator": ">", 36 | "value": 20000 37 | }] 38 | }, 39 | "sortInfo": [{ 40 | "field": "CloseDate", 41 | "direction": "ASC" 42 | }], 43 | "limit": 10000 44 | }; 45 | // Pass the query config to Apex through JavaScript Remoting. JSON should be serialized in advance. 46 | MyRemoteController.query(JSON.stringify(queryConfig), function(records, event) { 47 | if (event.status) { 48 | console.log(records); 49 | } else { 50 | console.error(event.message + ': ' + event.where); 51 | } 52 | }); 53 | ``` 54 | 55 | ## Comparison between other solutions 56 | 57 | 1. REST/SOAP API 58 | - Consumes API request quota 59 | 60 | 2. JavaScript Remoting 61 | - Doesn't have enough flexibility to build query in client side (JavaScript) 62 | 63 | 3. RemoteTK 64 | - Not secure because it doesn't check FLS, cannot be used in production 65 | - Recently removed from official Toolkit 66 | 67 | 4. Visualforce Remote Objects 68 | - Target Object/Field must be pre-defined in Page definition 69 | - Not supporting relationship query 70 | 71 | ## License 72 | 73 | MIT 74 | 75 | -------------------------------------------------------------------------------- /src/classes/MetadataValidator.cls: -------------------------------------------------------------------------------- 1 | public with sharing class MetadataValidator { 2 | 3 | private static Map cache = new Map {}; 4 | 5 | private static DescribeSObjectResult describeSObject(String sobjName) { 6 | sobjName = sobjName.toLowerCase(); 7 | DescribeSObjectResult res = MetadataValidator.cache.get(sobjName); 8 | if (res == null) { 9 | SObject so = (SObject) Type.forName(sobjName).newInstance(); 10 | res = so.getSObjectType().getDescribe(); 11 | MetadataValidator.cache.put(sobjName, res); 12 | } 13 | return res; 14 | } 15 | 16 | public static Boolean isObjectAccessible(String sobjName) { 17 | try { 18 | SObjectType soType = ((SObject) Type.forName(sobjName).newInstance()).getSObjectType(); 19 | return soType.getDescribe().isAccessible(); 20 | } catch(Exception e) { 21 | return false; 22 | } 23 | return true; 24 | } 25 | 26 | public static Boolean isFieldAccessible(String fieldName, String sobjName) { 27 | if (isObjectAccessible(sobjName)) { 28 | DescribeSObjectResult sobj = describeSObject(sobjName); 29 | Map fields = sobj.fields.getMap(); 30 | for (SObjectField f : fields.values()) { 31 | DescribeFieldResult field = f.getDescribe(); 32 | if (field.getName().toLowerCase() == fieldName.toLowerCase()) { 33 | return field.isAccessible(); 34 | } 35 | } 36 | } 37 | return false; 38 | } 39 | 40 | /* 41 | public static String getChildRelationshipObject(String childRelName, String sobjName) { 42 | if (isObjectAccessible(sobjName)) { 43 | DescribeSObjectResult sobj = describeSObject(sobjName); 44 | ChildRelationship[] childRels = sobj.getChildRelationships(); 45 | for (ChildRelationship childRel : childRels) { 46 | if (childRel.getRelationshipName().toLowerCase() == childRelName.toLowerCase()) { 47 | return childRel.getChildSObject().getDescribe().getName(); 48 | } 49 | } 50 | } 51 | return null; 52 | } 53 | */ 54 | 55 | public static String[] getParentRelationshipObjects(String parentRelName, String sobjName) { 56 | if (isObjectAccessible(sobjName)) { 57 | DescribeSObjectResult sobj = describeSObject(sobjName); 58 | Map fields = sobj.fields.getMap(); 59 | for (SObjectField f : fields.values()) { 60 | DescribeFieldResult field = f.getDescribe(); 61 | String relationshipName = field.getRelationshipName(); 62 | if (relationshipName != null && 63 | relationshipName.toLowerCase() == parentRelName.toLowerCase() && 64 | isFieldAccessible(field.getName(), sobjName)) { 65 | String[] parents = new String[] {}; 66 | for (SObjectType refType : field.getReferenceTo()) { 67 | parents.add(refType.getDescribe().getName()); 68 | } 69 | return parents; 70 | } 71 | } 72 | } 73 | return null; 74 | } 75 | 76 | } -------------------------------------------------------------------------------- /src/classes/Query.cls: -------------------------------------------------------------------------------- 1 | public with sharing class Query implements QueryElement { 2 | public Field[] fields { get; set; } 3 | public String table { get; set; } 4 | public Condition condition { get; set; } 5 | public SortInfo[] sortInfo { get; set; } 6 | public Integer queryLimit { get; set; } 7 | public Integer offset { get; set; } 8 | 9 | public Query(Map config) { 10 | this.table = (String) config.get('table'); 11 | System.debug('table = ' + this.table); 12 | this.fields = new Field[] {}; 13 | Object[] fields = (Object[]) config.get('fields'); 14 | if (fields != null) { 15 | for (Object field : fields) { 16 | System.debug('field = ' + field); 17 | this.fields.add(new Field((String) field, this.table)); 18 | } 19 | } 20 | Map cond = (Map) config.get('condition'); 21 | if (cond != null) { 22 | this.condition = ConditionBuilder.buildCondition(cond, this.table, true); 23 | } 24 | Object[] sortInfo = (Object[]) config.get('sortInfo'); 25 | if (sortInfo != null) { 26 | this.sortInfo = new SortInfo[] {}; 27 | for (Object s: sortInfo) { 28 | this.sortInfo.add(new SortInfo((Map) s, this.table)); 29 | } 30 | } 31 | this.queryLimit = (Integer) config.get('limit'); 32 | this.offset = (Integer) config.get('offset'); 33 | } 34 | 35 | public void validate() { 36 | System.debug('Validating Table: ' + this.table); 37 | if (!MetadataValidator.isObjectAccessible(this.table)) { 38 | throw new InvalidQueryException('Querying object is not available or not accessible : ' + this.table); 39 | } 40 | if (fields == null) { throw new InvalidQueryException('fields are not defined in query config'); } 41 | System.debug('Validating Fields: '); 42 | for (Field field : this.fields) { 43 | field.validate(); 44 | } 45 | System.debug('Validating Conditions: '); 46 | if (this.condition != null) { 47 | this.condition.validate(); 48 | } 49 | System.debug('Validating Sort Info: '); 50 | if (this.sortInfo != null) { 51 | for (SortInfo sortInfo : this.sortInfo) { 52 | sortInfo.validate(); 53 | } 54 | } 55 | System.debug('Validating Query Limit and Offset: '); 56 | if (this.queryLimit != null && this.queryLimit <= 0) { 57 | throw new InvalidQueryException('Query limit should be plus integer: ' + this.queryLimit); 58 | } 59 | if (this.offset != null && this.offset <= 0) { 60 | throw new InvalidQueryException('Query offset should be plus integer: ' + this.queryLimit); 61 | } 62 | } 63 | 64 | public String toSOQL() { 65 | String[] fieldStrings = new String[] {}; 66 | for (Field field : this.fields) { 67 | fieldStrings.add(field.toSOQL()); 68 | } 69 | String soql = 'SELECT ' + String.join(fieldStrings, ', ') + ' FROM ' + this.table; 70 | if (this.condition != null) { 71 | soql += ' WHERE ' + this.condition.toSOQL(); 72 | } 73 | if (this.sortInfo != null && this.sortInfo.size() > 0) { 74 | String[] sortInfoStrings = new String[] {}; 75 | for (SortInfo si: this.sortInfo) { 76 | sortInfoStrings.add(si.toSOQL()); 77 | } 78 | soql += ' ORDER BY ' + String.join(sortInfoStrings, ', '); 79 | } 80 | if (this.queryLimit != null) { 81 | soql += ' LIMIT ' + this.queryLimit; 82 | } 83 | return soql; 84 | } 85 | 86 | public List execute() { 87 | this.validate(); 88 | String soql = this.toSOQL(); 89 | return Database.query(soql); 90 | } 91 | 92 | } -------------------------------------------------------------------------------- /src/classes/SObjectFieldDescription.cls: -------------------------------------------------------------------------------- 1 | public with sharing class SObjectFieldDescription { 2 | 3 | @AuraEnabled 4 | public Boolean autoNumber { get; set; } 5 | @AuraEnabled 6 | public Integer byteLength { get; set; } 7 | @AuraEnabled 8 | public Boolean calculated { get; set; } 9 | @AuraEnabled 10 | public String calculatedFormula { get; set; } 11 | @AuraEnabled 12 | public Boolean cascadeDelete { get; set; } 13 | @AuraEnabled 14 | public Boolean caseSensitive { get; set; } 15 | @AuraEnabled 16 | public String controllerName { get; set; } 17 | @AuraEnabled 18 | public Boolean createable { get; set; } 19 | @AuraEnabled 20 | public Boolean custom { get; set; } 21 | @AuraEnabled 22 | public Object defaultValue { get; set; } 23 | @AuraEnabled 24 | public String defaultValueFormula { get; set; } 25 | @AuraEnabled 26 | public Boolean defaultedOnCreate { get; set; } 27 | @AuraEnabled 28 | public Boolean dependentPicklist { get; set; } 29 | @AuraEnabled 30 | public Boolean deprecatedAndHidden { get; set; } 31 | @AuraEnabled 32 | public Integer digits { get; set; } 33 | @AuraEnabled 34 | public Boolean displayLocationInDecimal { get; set; } 35 | @AuraEnabled 36 | public Boolean externalId { get; set; } 37 | @AuraEnabled 38 | public String extraTypeInfo { get; set; } 39 | @AuraEnabled 40 | public Boolean filterable { get; set; } 41 | @AuraEnabled 42 | public List filteredLookupInfo { get; set; } 43 | @AuraEnabled 44 | public Boolean groupable { get; set; } 45 | @AuraEnabled 46 | public Boolean highScaleNumber { get; set; } 47 | @AuraEnabled 48 | public Boolean htmlFormatted { get; set; } 49 | @AuraEnabled 50 | public Boolean idLookup { get; set; } 51 | @AuraEnabled 52 | public String inlineHelpText { get; set; } 53 | @AuraEnabled 54 | public String label { get; set; } 55 | @AuraEnabled 56 | public Integer length { get; set; } 57 | @AuraEnabled 58 | public String mask { get; set; } 59 | @AuraEnabled 60 | public String maskType { get; set; } 61 | @AuraEnabled 62 | public String name { get; set; } 63 | @AuraEnabled 64 | public Boolean nameField { get; set; } 65 | @AuraEnabled 66 | public Boolean namePointing { get; set; } 67 | @AuraEnabled 68 | public Boolean nillable { get; set; } 69 | @AuraEnabled 70 | public Boolean permissionable { get; set; } 71 | @AuraEnabled 72 | public List picklistValues { get; set; } 73 | @AuraEnabled 74 | public Integer precision { get; set; } 75 | @AuraEnabled 76 | public Boolean queryByDistance { get; set; } 77 | @AuraEnabled 78 | public String referenceTargetField { get; set; } 79 | @AuraEnabled 80 | public List referenceTo { get; set; } 81 | @AuraEnabled 82 | public String relationshipName { get; set; } 83 | @AuraEnabled 84 | public Integer relationshipOrder { get; set; } 85 | @AuraEnabled 86 | public Boolean restrictedDelete { get; set; } 87 | @AuraEnabled 88 | public Boolean restrictedPicklist { get; set; } 89 | @AuraEnabled 90 | public Integer scale { get; set; } 91 | @AuraEnabled 92 | public String soapType { get; set; } 93 | @AuraEnabled 94 | public Boolean sortable { get; set; } 95 | @AuraEnabled 96 | public String type { get; set; } 97 | @AuraEnabled 98 | public Boolean unique { get; set; } 99 | @AuraEnabled 100 | public Boolean updateable { get; set; } 101 | @AuraEnabled 102 | public Boolean writeRequiresMasterRead { get; set; } 103 | 104 | public SObjectFieldDescription(String name, String label) { 105 | this.name = name; 106 | this.label = label; 107 | } 108 | 109 | } -------------------------------------------------------------------------------- /src/classes/QueryTest.cls: -------------------------------------------------------------------------------- 1 | @isTest 2 | private class QueryTest { 3 | 4 | @isTest 5 | static void testQuery() { 6 | Map config = (Map) JSON.deserializeUntyped( 7 | '{' + 8 | ' "fields" : ["Id", "Name", "Account.Id", "Account.Name"], ' + 9 | ' "table" : "Opportunity", ' + 10 | ' "condition" : { ' + 11 | ' "operator" : "AND", ' + 12 | ' "conditions": [{ ' + 13 | ' "operator": "NOT", ' + 14 | ' "condition": { ' + 15 | ' "field" : "Account.Name", ' + 16 | ' "operator" : "LIKE", ' + 17 | ' "value" : "%a%" ' + 18 | ' } ' + 19 | ' }, { ' + 20 | ' "field" : "Amount", ' + 21 | ' "operator" : ">=", ' + 22 | ' "value" : 5000 ' + 23 | ' }, { ' + 24 | ' "field" : "Type", ' + 25 | ' "operator" : "!=", ' + 26 | ' "value" : null ' + 27 | ' }, { ' + 28 | ' "field" : "StageName", ' + 29 | ' "operator" : "IN", ' + 30 | ' "value" : [ "Prospecting", "Value Proposition", "Qualification" ] ' + 31 | ' }, { ' + 32 | ' "operator" : "OR", ' + 33 | ' "conditions": [{' + 34 | ' "field" : "Account.Owner.Username", ' + 35 | ' "operator" : "!=", ' + 36 | ' "value" : "user01@example.com" ' + 37 | ' }, { ' + 38 | ' "field" : "Account.Owner.IsActive", ' + 39 | ' "operator" : "=", ' + 40 | ' "value" : false ' + 41 | ' }, { ' + 42 | ' "operator" : "AND", ' + 43 | ' "conditions": [{' + 44 | ' "field" : "CloseDate", ' + 45 | ' "operator" : ">=", ' + 46 | ' "value" : { ' + 47 | ' "type" : "date", ' + 48 | ' "value" : "2008-01-01" ' + 49 | ' } ' + 50 | ' }, { ' + 51 | ' "field" : "CloseDate", ' + 52 | ' "operator" : "<", ' + 53 | ' "value" : { ' + 54 | ' "type" : "date", ' + 55 | ' "value" : "TODAY" ' + 56 | ' } ' + 57 | ' }] ' + 58 | ' }] ' + 59 | ' }] ' + 60 | ' }, ' + 61 | ' "sortInfo": [{ ' + 62 | ' "field" : "Account.Type", ' + 63 | ' "direction" : "ASC", ' + 64 | ' "nullOrder" : "LAST" ' + 65 | ' }, { ' + 66 | ' "field" : "Amount", ' + 67 | ' "direction" : "DESC" ' + 68 | ' }], ' + 69 | ' "limit" : 1000 ' + 70 | '}' 71 | ); 72 | Query q = new Query(config); 73 | q.validate(); 74 | String soql = q.toSOQL(); 75 | System.debug(soql); 76 | String expectedSOQL = 77 | 'SELECT Id, Name, Account.Id, Account.Name ' + 78 | 'FROM Opportunity ' + 79 | 'WHERE ' + 80 | '(NOT Account.Name LIKE \'%a%\') ' + 81 | 'AND ' + 82 | 'Amount >= 5000 ' + 83 | 'AND ' + 84 | 'Type != null ' + 85 | 'AND ' + 86 | 'StageName IN (\'Prospecting\', \'Value Proposition\', \'Qualification\') ' + 87 | 'AND ' + 88 | '(' + 89 | 'Account.Owner.Username != \'user01@example.com\' ' + 90 | 'OR ' + 91 | 'Account.Owner.IsActive = false ' + 92 | 'OR ' + 93 | '(CloseDate >= 2008-01-01 AND CloseDate < TODAY)' + 94 | ') ' + 95 | 'ORDER BY Account.Type ASC NULLS LAST, Amount DESC ' + 96 | 'LIMIT 1000'; 97 | System.assert(soql == expectedSOQL, 'unexpected SOQL: ' + soql + '\n expected : ' + expectedSOQL); 98 | 99 | List records = q.execute(); 100 | for (SObject rec : records) { 101 | Opportunity opp = (Opportunity) rec; 102 | System.assert(opp.Account.Name.substring(0, 1) == 'B'); 103 | System.assert(opp.Amount > 50000); 104 | } 105 | } 106 | 107 | } -------------------------------------------------------------------------------- /src/classes/OperandTypes.cls: -------------------------------------------------------------------------------- 1 | public with sharing class OperandTypes { 2 | 3 | public static Operand createOperand(Object v) { 4 | if (v == null) { 5 | return new OperandTypes.NullOperand(); 6 | } else if (v instanceOf String) { 7 | return new OperandTypes.StringOperand((String) v); 8 | } else if (v instanceOf Decimal) { 9 | return new OperandTypes.DecimalOperand((Decimal) v); 10 | } else if (v instanceOf Boolean) { 11 | return new OperandTypes.BooleanOperand((Boolean) v); 12 | } else if (v instanceOf Object[]) { 13 | return new OperandTypes.ArrayOperand((Object[]) v); 14 | } else if (v instanceOf Map) { 15 | Map m = (Map) v; 16 | String type = ((String) m.get('type')); 17 | if (type == 'date' || type == 'datetime') { 18 | return new OperandTypes.DateOperand((String) m.get('value')); 19 | } else if (m.get('table') instanceOf String) { 20 | // this.value = new OperandTypes.SubQueryOperand(m); 21 | } 22 | } 23 | return null; 24 | } 25 | 26 | public class NullOperand implements Operand { 27 | public void validate() {} 28 | public String toSOQL() { 29 | return 'null'; 30 | } 31 | } 32 | 33 | public class StringOperand implements Operand { 34 | public String value { get; set; } 35 | public StringOperand(String value) { 36 | this.value = value; 37 | } 38 | public void validate() {} 39 | public String toSOQL() { 40 | return '\'' + String.escapeSingleQuotes(this.value) + '\''; 41 | } 42 | } 43 | 44 | public class DecimalOperand implements Operand { 45 | public Decimal value { get; set; } 46 | public DecimalOperand(Decimal value) { 47 | this.value = value; 48 | } 49 | public void validate() {} 50 | public String toSOQL() { 51 | return String.valueOf(this.value); 52 | } 53 | } 54 | 55 | public class BooleanOperand implements Operand { 56 | public Boolean value { get; set; } 57 | public BooleanOperand(Boolean value) { 58 | this.value = value; 59 | } 60 | public void validate() {} 61 | public String toSOQL() { 62 | return String.valueOf(this.value); 63 | } 64 | } 65 | 66 | public class ArrayOperand implements Operand { 67 | public Operand[] values { get; set; } 68 | public ArrayOperand(Object[] values) { 69 | this.values = new Operand[] {}; 70 | for (Object value : values) { 71 | this.values.add(OperandTypes.createOperand(value)); 72 | } 73 | } 74 | public void validate() {} 75 | public String toSOQL() { 76 | String[] valueStrings = new String[] {}; 77 | for (Operand value : this.values) { 78 | valueStrings.add(value.toSOQL()); 79 | } 80 | return '('+String.join(valueStrings, ', ')+')'; 81 | } 82 | } 83 | 84 | public static Pattern DATE_OPERAND_REGEXP = 85 | Pattern.compile('^(\\d{4}-\\d{2}-\\d{2}(T\\d{2}:?\\d{2}:?\\d{2}(\\.\\d+)?([\\+\\-]\\d{2}:?\\d{2}|Z))?|' + 86 | 'YESTERDAY|TODAY|TOMORROW|(NEXT|THIS|LAST)_(WEEK|MONTH|(FISCAL_)?(QUARTER|YEAR))|' + 87 | '(NEXT|LAST)_90_DAYS|' + 88 | '(NEXT|LAST)_N_(DAYS|WEEKS|MONTHS|(FISCAL_)?(QUARTERS|YEARS)):\\d+)$'); 89 | 90 | public class DateOperand implements Operand { 91 | public String value { get; set; } 92 | public DateOperand(String value) { 93 | this.value = value; 94 | } 95 | public void validate() { 96 | if (!DATE_OPERAND_REGEXP.matcher(this.value).matches()) { 97 | throw new InvalidQueryException('Operand date value is invalid : ' + this.value); 98 | } 99 | } 100 | public String toSOQL() { 101 | return value; 102 | } 103 | } 104 | 105 | /* 106 | public class QueryOperand implements Operand { 107 | public Query query { get; set; } 108 | public QueryOperand(Map value) { 109 | this.query = new Query(value); 110 | } 111 | public String toSOQL() { 112 | return query.toSOQL(); 113 | } 114 | } 115 | */ 116 | 117 | } -------------------------------------------------------------------------------- /src/classes/Metadata.cls: -------------------------------------------------------------------------------- 1 | public with sharing class Metadata { 2 | 3 | /** 4 | * 5 | */ 6 | public static SObjectDescription[] describeGlobal() { 7 | Map gd = Schema.getGlobalDescribe(); 8 | SObjectDescription[] soList = new SObjectDescription[]{ }; 9 | for (SObjectType soType : gd.values()) { 10 | DescribeSObjectResult d = soType.getDescribe(); 11 | if (d.isAccessible()) { 12 | soList.add(new SObjectDescription(d.getName(), d.getLabel())); 13 | } 14 | } 15 | return soList; 16 | } 17 | 18 | /** 19 | * 20 | */ 21 | public static SObjectDescription describeSObject(String sobjectName) { 22 | SObject so = (SObject) Type.forName(sobjectName).newInstance(); 23 | DescribeSObjectResult d = so.getSObjectType().getDescribe(); 24 | if (d.isAccessible()) { 25 | SObjectFieldDescription[] fields = new SObjectFieldDescription[]{ }; 26 | for (SObjectField f: d.fields.getMap().values()) { 27 | DescribeFieldResult fd = f.getDescribe(); 28 | if (fd.isAccessible()) { 29 | fields.add(convertToSObjectField(fd)); 30 | } 31 | } 32 | return new SObjectDescription(d.getName(), d.getLabel(), fields); 33 | } else { 34 | return null; 35 | } 36 | } 37 | 38 | 39 | private static SObjectFieldDescription convertToSObjectField(DescribeFieldResult fd) { 40 | SObjectFieldDescription field = new SObjectFieldDescription(fd.getName(), fd.getLabel()); 41 | field.autoNumber = fd.isAutoNumber(); 42 | field.calculated = fd.isCalculated(); 43 | field.byteLength = fd.getByteLength(); 44 | field.calculated = fd.isCalculated(); 45 | field.calculatedFormula = fd.getCalculatedFormula(); 46 | field.cascadeDelete = fd.isCascadeDelete(); 47 | field.caseSensitive = fd.isCaseSensitive(); 48 | // field.controllerName = fd.getControllerName(); 49 | field.createable = fd.isCreateable(); 50 | field.custom = fd.isCustom(); 51 | field.defaultValue = fd.getDefaultValue(); 52 | field.defaultValueFormula = fd.getDefaultValueFormula(); 53 | field.defaultedOnCreate = fd.isDefaultedOnCreate(); 54 | field.dependentPicklist = fd.isDependentPicklist(); 55 | field.deprecatedAndHidden = fd.isDeprecatedAndHidden(); 56 | field.digits = fd.getDigits(); 57 | field.displayLocationInDecimal = fd.isDisplayLocationInDecimal(); 58 | field.externalId = fd.isExternalId(); 59 | //public String extraTypeInfo { get; set; } 60 | 61 | field.filterable = fd.isFilterable(); 62 | //public List filteredLookupInfo { get; set; } 63 | field.groupable = fd.isGroupable(); 64 | // field.highScaleNumber = fd.isHighScaleNumber(); 65 | field.htmlFormatted = fd.isHtmlFormatted(); 66 | field.idLookup = fd.isIdLookup(); 67 | field.inlineHelpText = fd.getInlineHelpText(); 68 | field.length = fd.getLength(); 69 | field.mask = fd.getMask(); 70 | field.maskType = fd.getMaskType(); 71 | field.nameField = fd.isNameField(); 72 | field.namePointing = fd.isNamePointing(); 73 | field.nillable = fd.isNillable(); 74 | field.permissionable = fd.isPermissionable(); 75 | field.picklistValues = fd.getPicklistValues(); 76 | field.precision = fd.getPrecision(); 77 | field.queryByDistance = fd.isQueryByDistance(); 78 | field.referenceTargetField = fd.getReferenceTargetField(); 79 | field.referenceTo = convertReferenceToObjectNames(fd.getReferenceTo()); 80 | field.relationshipName = fd.getRelationshipName(); 81 | field.relationshipOrder = fd.getRelationshipOrder(); 82 | field.restrictedDelete = fd.isRestrictedDelete(); 83 | field.restrictedPicklist = fd.isRestrictedPicklist(); 84 | field.scale = fd.getScale(); 85 | // field.soapType = fd.getSoapType(); 86 | field.sortable = fd.isSortable(); 87 | field.type = displayTypeMap.get(fd.getType()); 88 | field.unique = fd.isUnique(); 89 | field.updateable = fd.isUpdateable(); 90 | field.writeRequiresMasterRead = fd.isWriteRequiresMasterRead(); 91 | 92 | return field; 93 | } 94 | 95 | private static Map displayTypeMap = new Map { 96 | Schema.DisplayType.STRING => 'string', 97 | Schema.DisplayType.BOOLEAN => 'boolean', 98 | Schema.DisplayType.DOUBLE => 'double', 99 | Schema.DisplayType.INTEGER => 'integer', 100 | Schema.DisplayType.PERCENT => 'percent', 101 | Schema.DisplayType.CURRENCY => 'currency', 102 | Schema.DisplayType.DATE => 'date', 103 | Schema.DisplayType.DATETIME => 'datetime', 104 | Schema.DisplayType.TIME => 'time', 105 | Schema.DisplayType.PICKLIST => 'picklist', 106 | Schema.DisplayType.MULTIPICKLIST => 'multipicklist', 107 | Schema.DisplayType.DATACATEGORYGROUPREFERENCE => 'datacategorygroupreference', 108 | Schema.DisplayType.BASE64 => 'base64', 109 | Schema.DisplayType.ID => 'id', 110 | Schema.DisplayType.REFERENCE => 'reference', 111 | Schema.DisplayType.TEXTAREA => 'textarea', 112 | Schema.DisplayType.PHONE => 'phone', 113 | Schema.DisplayType.COMBOBOX => 'combobox', 114 | Schema.DisplayType.URL => 'url', 115 | Schema.DisplayType.EMAIL => 'email', 116 | Schema.DisplayType.ANYTYPE => 'anytype', 117 | Schema.DisplayType.LOCATION => 'location', 118 | Schema.DisplayType.ENCRYPTEDSTRING => 'encryptedstring', 119 | Schema.DisplayType.COMPLEXVALUE => 'complexvalue', 120 | Schema.DisplayType.ADDRESS => 'address', 121 | Schema.DisplayType.SOBJECT => 'sobject' 122 | }; 123 | 124 | private static String[] convertReferenceToObjectNames(SObjectType[] referenceTo) { 125 | if (referenceTo == null) { 126 | return null; 127 | } 128 | String[] soNames = new String[]{ }; 129 | for (SObjectType soType : referenceTo) { 130 | DescribeSObjectResult d = soType.getDescribe(); 131 | if (d.isAccessible()) { 132 | soNames.add(d.getName()); 133 | } 134 | } 135 | return soNames; 136 | } 137 | 138 | } 139 | --------------------------------------------------------------------------------