diff --git a/source/classes/DatabaseLayer.cls b/source/classes/DatabaseLayer.cls index e487e2b..657e773 100644 --- a/source/classes/DatabaseLayer.cls +++ b/source/classes/DatabaseLayer.cls @@ -173,6 +173,23 @@ global class DatabaseLayer { return query?.setFrom(objectType)?.toSoql(); } + /** + * @description Creates a new SOQL query builder from a SOQL query string. + * Parses the provided SOQL string and constructs a Soql.Builder instance with the parsed components. + * + * @param soqlString The SOQL query string to parse (e.g., 'SELECT Id, Name FROM Account WHERE CreatedDate = TODAY LIMIT 10') + * @return A configured SOQL builder instance populated from the parsed query string + * @throws Soql.Parser.ParserException if the query string is invalid or cannot be parsed + * + * @example + * String queryStr = 'SELECT Id, Name FROM Account WHERE CreatedDate = TODAY ORDER BY Name ASC LIMIT 10'; + * Soql query = DatabaseLayer.Soql.newQuery(queryStr); + * List accounts = query.query(); + */ + global Soql newQuery(String soqlString) { + return Soql.Parser.fromString(soqlString, INSTANCE)?.toSoql(); + } + private DatabaseLayer.SoqlProvider useMocks(Boolean value) { this.useMocks = value; return this; diff --git a/source/classes/MockDmlTest.cls b/source/classes/MockDmlTest.cls index 75029f5..363a82f 100644 --- a/source/classes/MockDmlTest.cls +++ b/source/classes/MockDmlTest.cls @@ -51,7 +51,7 @@ private class MockDmlTest { } } - @IsTest + @IsTest private static void shouldMockConvertWithNoOpportunity() { Lead lead = (Lead) new MockRecord(Lead.SObjectType)?.withId()?.toSObject(); Database.LeadConvert leadToConvert = new Database.LeadConvert(); diff --git a/source/classes/Soql.cls b/source/classes/Soql.cls index d08ef88..4fd5279 100644 --- a/source/classes/Soql.cls +++ b/source/classes/Soql.cls @@ -2261,4 +2261,727 @@ global inherited sharing virtual class Soql extends Soql.Builder { return this.thenSelect(field, null); } } + + /** + * @description Utility class for parsing SOQL query strings and constructing Soql.Builder instances. + * This class enables creating Soql objects from SOQL strings like: + * DatabaseLayer.Soql.newQuery('SELECT Id, Name FROM Account WHERE CreatedDate = TODAY LIMIT 10') + */ + @SuppressWarnings('PMD.CyclomaticComplexity, PMD.CognitiveComplexity, PMD.ExcessivePublicCount') + global inherited sharing class Parser { + // Clause patterns for extracting different parts of the SOQL query + private static final Pattern SELECT_PATTERN = Pattern.compile('(?i)^SELECT\\s+(.+?)\\s+FROM'); + private static final Pattern FROM_PATTERN = Pattern.compile('(?i)FROM\\s+([\\w]+)'); + private static final Pattern WHERE_PATTERN = Pattern.compile( + '(?i)WHERE\\s+(.+?)(?:\\s+(?:GROUP BY|ORDER BY|LIMIT|OFFSET|FOR|USING|WITH|$))' + ); + private static final Pattern ORDER_BY_PATTERN = Pattern.compile( + '(?i)ORDER BY\\s+(.+?)(?:\\s+(?:LIMIT|OFFSET|FOR|USING|WITH|$))' + ); + private static final Pattern GROUP_BY_PATTERN = Pattern.compile( + '(?i)GROUP BY\\s+(.+?)(?:\\s+(?:HAVING|ORDER BY|LIMIT|OFFSET|FOR|USING|WITH|$))' + ); + private static final Pattern HAVING_PATTERN = Pattern.compile( + '(?i)HAVING\\s+(.+?)(?:\\s+(?:ORDER BY|LIMIT|OFFSET|FOR|USING|WITH|$))' + ); + private static final Pattern LIMIT_PATTERN = Pattern.compile('(?i)LIMIT\\s+(\\d+)'); + private static final Pattern OFFSET_PATTERN = Pattern.compile('(?i)OFFSET\\s+(\\d+)'); + private static final Pattern SCOPE_PATTERN = Pattern.compile('(?i)USING SCOPE\\s+(\\w+)'); + private static final Pattern USAGE_PATTERN = Pattern.compile('(?i)FOR\\s+(VIEW|UPDATE|REFERENCE)'); + private static final Pattern SECURITY_PATTERN = Pattern.compile('(?i)WITH\\s+SECURITY_ENFORCED'); + private static final Pattern ALL_ROWS_PATTERN = Pattern.compile('(?i)ALL\\s+ROWS'); + + // Operator patterns + private static final Map OPERATOR_MAP = new Map{ + '=' => Soql.EQUALS, + '!=' => Soql.NOT_EQUALS, + '<' => Soql.LESS_THAN, + '<=' => Soql.LESS_THAN_OR_EQUAL_TO, + '>' => Soql.GREATER_THAN, + '>=' => Soql.GREATER_THAN_OR_EQUAL_TO, + 'LIKE' => Soql.LIKE, + 'IN' => Soql.IS_IN, + 'NOT IN' => Soql.NOT_IN, + 'INCLUDES' => Soql.INCLUDES, + 'EXCLUDES' => Soql.EXCLUDES + }; + + private Soql.Builder query; + private DatabaseLayer factory; + + /** + * @description Private constructor - use fromString() factory method instead + * @param factory The DatabaseLayer instance to use for creating the Soql object + */ + private Parser(DatabaseLayer factory) { + this.factory = factory; + this.query = new Soql(factory); + } + + /** + * @description Factory method to create a parser and parse a SOQL string + * This is the only way to construct a Parser instance - constructor is private + * @param soqlString The SOQL query string to parse + * @param factory The DatabaseLayer instance to use for creating the Soql object + * @return A Soql.Builder instance populated with the parsed query components + * @throws ParserException if the query string is invalid or cannot be parsed + */ + global static Soql.Builder fromString(String soqlString, DatabaseLayer factory) { + Parser parser = new Parser(factory); + return parser.parse(soqlString); + } + + /** + * @description Parses a SOQL query string and returns a populated Soql.Builder instance + * @param soqlString The SOQL query string to parse + * @return A Soql.Builder instance populated with the parsed query components + * @throws ParserException if the query string is invalid or cannot be parsed + */ + private Soql.Builder parse(String soqlString) { + if (String.isBlank(soqlString)) { + throw new ParserException('SOQL query string cannot be blank'); + } + + String normalized = this.normalizeWhitespace(soqlString); + this.parseFrom(normalized); + this.parseSelect(normalized); + this.parseWhere(normalized); + this.parseGroupBy(normalized); + this.parseHaving(normalized); + this.parseOrderBy(normalized); + this.parseLimit(normalized); + this.parseOffset(normalized); + this.parseScope(normalized); + this.parseUsage(normalized); + this.parseSecurity(normalized); + this.parseAllRows(normalized); + + return this.query; + } + + /** + * @description Normalizes whitespace in the query string while preserving quoted strings + */ + private String normalizeWhitespace(String soqlString) { + return soqlString?.trim()?.replaceAll('\\s+', ' '); + } + + /** + * @description Parses the FROM clause to determine the SObject type + */ + private void parseFrom(String soqlString) { + Matcher fromMatcher = FROM_PATTERN.matcher(soqlString); + if (!fromMatcher.find()) { + throw new ParserException('Invalid SOQL: Missing FROM clause'); + } + + String objectName = fromMatcher.group(1)?.trim(); + if (String.isBlank(objectName)) { + throw new ParserException('Invalid SOQL: FROM clause is empty'); + } + + SObjectType objectType = this.getSObjectType(objectName); + if (objectType == null) { + throw new ParserException('Invalid SObject type: ' + objectName); + } + + this.query.setFrom(objectType); + } + + /** + * @description Parses the SELECT clause to add field selections + */ + private void parseSelect(String soqlString) { + Matcher selectMatcher = SELECT_PATTERN.matcher(soqlString); + if (!selectMatcher.find()) { + throw new ParserException('Invalid SOQL: Missing SELECT clause'); + } + + String selectClause = selectMatcher.group(1)?.trim(); + if (String.isBlank(selectClause)) { + throw new ParserException('Invalid SOQL: SELECT clause is empty'); + } + + List fields = this.splitByCommaRespectingParentheses(selectClause); + + for (String field : fields) { + field = field?.trim(); + if (String.isNotBlank(field)) { + this.parseSelectField(field); + } + } + } + + /** + * @description Parses a single SELECT field (can be a field name, function, or subquery) + */ + private void parseSelectField(String field) { + if (field.equalsIgnoreCase('COUNT()')) { + this.query.addSelect(new Soql.Aggregation(Soql.Function.COUNT)); + return; + } + + if ( + field.toUpperCase().startsWith('COUNT(') || + field.toUpperCase().startsWith('AVG(') || + field.toUpperCase().startsWith('SUM(') || + field.toUpperCase().startsWith('MIN(') || + field.toUpperCase().startsWith('MAX(') + ) { + this.parseAggregateFunction(field); + return; + } + + if (field.startsWith('(') && field.endsWith(')')) { + // Subquery parsing would require recursive parsing + // This is a limitation of the initial implementation + return; + } + + if (field.toUpperCase().startsWith('TYPEOF')) { + // Polymorphic TYPEOF queries are complex and skipped for now + // This is a limitation of the initial implementation + return; + } + + String fieldName = field; + String alias = null; + + if (field.toUpperCase().contains(' AS ')) { + List parts = field.split('(?i)\\s+AS\\s+', 2); + fieldName = parts[0]?.trim(); + alias = parts.size() > 1 ? parts[1]?.trim() : null; + } else { + List parts = field.split('\\s+'); + if (parts.size() == 2 && !parts[1].toUpperCase().equals('FROM')) { + fieldName = parts[0]?.trim(); + alias = parts[1]?.trim(); + } + } + + if (fieldName.contains('.')) { + this.query.addSelect(this.parseParentField(fieldName)); + } else { + this.query.addSelect(fieldName); + } + } + + /** + * @description Parses an aggregate function like COUNT(Id), AVG(Amount), etc. + */ + private void parseAggregateFunction(String functionStr) { + // Extract function name and field + Integer openParen = functionStr.indexOf('('); + Integer closeParen = functionStr.lastIndexOf(')'); + + if (openParen < 0 || closeParen < 0) { + return; // Invalid function format + } + + String functionName = functionStr.substring(0, openParen)?.trim()?.toUpperCase(); + String fieldName = functionStr.substring(openParen + 1, closeParen)?.trim(); + + Soql.Function func = null; + switch on functionName { + when 'COUNT' { + func = Soql.Function.COUNT; + } + when 'AVG' { + func = Soql.Function.AVG; + } + when 'SUM' { + func = Soql.Function.SUM; + } + when 'MIN' { + func = Soql.Function.MIN; + } + when 'MAX' { + func = Soql.Function.MAX; + } + when 'COUNT_DISTINCT' { + func = Soql.Function.COUNT_DISTINCT; + } + } + + if (func != null) { + if (String.isBlank(fieldName)) { + this.query.addSelect(new Soql.Aggregation(func)); + } else { + if (fieldName.contains('.')) { + this.query.addSelect(new Soql.Aggregation(func, this.parseParentField(fieldName))); + } else { + this.query.addSelect(new Soql.Aggregation(func, fieldName)); + } + } + } + } + + /** + * @description Parses a parent field reference like Account.Name or Owner.Profile.Name + */ + private Soql.ParentField parseParentField(String fieldPath) { + List parts = fieldPath.split('\\.'); + + if (parts.size() == 2) { + // One level deep (e.g., Account.Name) + return new Soql.ParentField(parts[0], parts[1]); + } else if (parts.size() == 3) { + // Two levels deep (e.g., Account.Owner.Name) + return new Soql.ParentField(parts[0], parts[1], parts[2]); + } else if (parts.size() == 4) { + // Three levels deep + return new Soql.ParentField(parts[0], parts[1], parts[2], parts[3]); + } else if (parts.size() == 5) { + // Four levels deep + return new Soql.ParentField(parts[0], parts[1], parts[2], parts[3], parts[4]); + } + + // Fallback: return null or throw exception + return null; + } + + /** + * @description Parses the WHERE clause to add filter conditions + */ + private void parseWhere(String soqlString) { + Matcher whereMatcher = WHERE_PATTERN.matcher(soqlString); + if (!whereMatcher.find()) { + return; // WHERE clause is optional + } + + String whereClause = whereMatcher.group(1)?.trim(); + if (String.isBlank(whereClause)) { + return; + } + + // Parse WHERE conditions + this.parseWhereConditions(whereClause); + } + + /** + * @description Parses WHERE conditions (supports simple conditions with AND/OR) + */ + private void parseWhereConditions(String whereClause) { + // For initial implementation, support simple conditions separated by AND/OR + // Complex nested logic with parentheses would require more sophisticated parsing + + // Check if it's an OR condition + if (whereClause.toUpperCase().contains(' OR ') && !whereClause.contains('(')) { + this.query.setOuterWhereLogic(Soql.LogicType.ANY_CONDITIONS); + List conditions = whereClause.split('(?i)\\s+OR\\s+'); + for (String condition : conditions) { + this.parseSingleCondition(condition?.trim()); + } + } + // Check if it's an AND condition + else if (whereClause.toUpperCase().contains(' AND ') && !whereClause.contains('(')) { + this.query.setOuterWhereLogic(Soql.LogicType.ALL_CONDITIONS); + List conditions = whereClause.split('(?i)\\s+AND\\s+'); + for (String condition : conditions) { + this.parseSingleCondition(condition?.trim()); + } + } + // Single condition + else if (!whereClause.contains('(')) { + this.parseSingleCondition(whereClause); + } + // Complex nested logic - skip for now + // This is a limitation of the initial implementation + } + + /** + * @description Parses a single WHERE condition like "Name = 'Test'" or "Id = :recordId" + */ + private void parseSingleCondition(String condition) { + // Find the operator + Soql.Operator operator = null; + String operatorStr = null; + Integer operatorPos = -1; + + // Check for two-character operators first + for (String op : new List{ '!=', '<=', '>=', 'NOT IN', 'INCLUDES', 'EXCLUDES' }) { + Integer pos = condition.toUpperCase().indexOf(' ' + op + ' '); + if (pos >= 0) { + operator = OPERATOR_MAP.get(op); + operatorStr = op; + operatorPos = pos; + break; + } + } + + // Check for single-character operators + if (operator == null) { + for (String op : new List{ '=', '<', '>', 'LIKE', 'IN' }) { + Integer pos = condition.indexOf(' ' + op + ' '); + if (pos >= 0 || (op.length() == 1 && condition.indexOf(op) >= 0)) { + operator = OPERATOR_MAP.get(op); + operatorStr = op; + operatorPos = (pos >= 0) ? pos : condition.indexOf(op); + break; + } + } + } + + if (operator == null || operatorPos < 0) { + return; // Cannot parse this condition + } + + // Extract field and value + String fieldStr = condition.substring(0, operatorPos)?.trim(); + String valueStr = condition.substring(operatorPos + operatorStr.length() + 1)?.trim(); + + // Parse field + Object field = null; + if (fieldStr.contains('.')) { + field = this.parseParentField(fieldStr); + } else { + field = fieldStr; + } + + // Parse value + Object value = this.parseValue(valueStr); + + // Add condition to query + if (field instanceof String) { + this.query.addWhere((String) field, operator, value); + } else if (field instanceof Soql.ParentField) { + this.query.addWhere((Soql.ParentField) field, operator, value); + } + } + + /** + * @description Parses a value in a WHERE clause (can be a literal, bind variable, or date function) + */ + private Object parseValue(String valueStr) { + if (String.isBlank(valueStr)) { + return null; + } + + valueStr = valueStr.trim(); + + // Check for bind variable + if (valueStr.startsWith(':')) { + return new Soql.Binder(valueStr.substring(1)); + } + + // Check for null + if (valueStr.equalsIgnoreCase('null')) { + return null; + } + + // Check for boolean + if (valueStr.equalsIgnoreCase('true')) { + return true; + } + if (valueStr.equalsIgnoreCase('false')) { + return false; + } + + // Check for date literals + if (valueStr.equalsIgnoreCase('TODAY')) { + return Date.today(); + } + if (valueStr.equalsIgnoreCase('YESTERDAY')) { + return Date.today().addDays(-1); + } + if (valueStr.equalsIgnoreCase('TOMORROW')) { + return Date.today().addDays(1); + } + + // Check for string literal (single quotes) + if (valueStr.startsWith('\'') && valueStr.endsWith('\'')) { + return valueStr.substring(1, valueStr.length() - 1).replace('\\\'', '\''); + } + + // Check for number + if (valueStr.isNumeric() || (valueStr.startsWith('-') && valueStr.substring(1).isNumeric())) { + return Decimal.valueOf(valueStr); + } + + // Default: return as string + return valueStr; + } + + /** + * @description Parses the ORDER BY clause + */ + private void parseOrderBy(String soqlString) { + Matcher orderByMatcher = ORDER_BY_PATTERN.matcher(soqlString); + if (!orderByMatcher.find()) { + return; // ORDER BY is optional + } + + String orderByClause = orderByMatcher.group(1)?.trim(); + if (String.isBlank(orderByClause)) { + return; + } + + // Split by comma to handle multiple ORDER BY fields + List orderByFields = orderByClause.split(','); + + for (String orderByField : orderByFields) { + this.parseOrderByField(orderByField?.trim()); + } + } + + /** + * @description Parses a single ORDER BY field with direction and null ordering + */ + private void parseOrderByField(String orderByStr) { + String fieldStr = orderByStr; + Soql.SortDirection direction = Soql.SortDirection.ASCENDING; + Soql.NullOrder nullOrder = null; + + // Check for DESC/ASC + if (orderByStr.toUpperCase().contains(' DESC')) { + direction = Soql.SortDirection.DESCENDING; + fieldStr = orderByStr.substring(0, orderByStr.toUpperCase().indexOf(' DESC'))?.trim(); + } else if (orderByStr.toUpperCase().contains(' ASC')) { + direction = Soql.SortDirection.ASCENDING; + fieldStr = orderByStr.substring(0, orderByStr.toUpperCase().indexOf(' ASC'))?.trim(); + } + + // Check for NULLS FIRST/LAST + if (orderByStr.toUpperCase().contains('NULLS FIRST')) { + nullOrder = Soql.NullOrder.NULLS_FIRST; + } else if (orderByStr.toUpperCase().contains('NULLS LAST')) { + nullOrder = Soql.NullOrder.NULLS_LAST; + } + + // Add ORDER BY to query + if (fieldStr.contains('.')) { + Soql.ParentField parentField = this.parseParentField(fieldStr); + if (nullOrder != null) { + this.query.addOrderBy(new Soql.SortOrder(parentField, direction, nullOrder)); + } else { + this.query.addOrderBy(parentField, direction); + } + } else { + if (nullOrder != null) { + this.query.addOrderBy(new Soql.SortOrder(fieldStr, direction, nullOrder)); + } else { + this.query.addOrderBy(fieldStr, direction); + } + } + } + + /** + * @description Parses the GROUP BY clause + */ + private void parseGroupBy(String soqlString) { + Matcher groupByMatcher = GROUP_BY_PATTERN.matcher(soqlString); + if (!groupByMatcher.find()) { + return; // GROUP BY is optional + } + + String groupByClause = groupByMatcher.group(1)?.trim(); + if (String.isBlank(groupByClause)) { + return; + } + + // Split by comma for multiple GROUP BY fields + List fields = groupByClause.split(','); + + for (String field : fields) { + field = field?.trim(); + if (String.isNotBlank(field)) { + if (field.contains('.')) { + this.query.addGroupBy(this.parseParentField(field)); + } else { + this.query.addGroupBy(field); + } + } + } + } + + /** + * @description Parses the HAVING clause + */ + private void parseHaving(String soqlString) { + Matcher havingMatcher = HAVING_PATTERN.matcher(soqlString); + if (!havingMatcher.find()) { + return; // HAVING is optional + } + + String havingClause = havingMatcher.group(1)?.trim(); + if (String.isBlank(havingClause)) { + return; + } + + // Parse HAVING conditions (similar to WHERE but with aggregate functions) + this.parseHavingConditions(havingClause); + } + + /** + * @description Parses HAVING conditions + */ + private void parseHavingConditions(String havingClause) { + // For now, skip complex HAVING parsing + // This is a limitation of the initial implementation + // HAVING conditions typically involve aggregate functions which need special handling + } + + /** + * @description Parses the LIMIT clause + */ + private void parseLimit(String soqlString) { + Matcher limitMatcher = LIMIT_PATTERN.matcher(soqlString); + if (!limitMatcher.find()) { + return; // LIMIT is optional + } + + String limitStr = limitMatcher.group(1); + if (String.isNotBlank(limitStr)) { + this.query.setRowLimit(Integer.valueOf(limitStr)); + } + } + + /** + * @description Parses the OFFSET clause + */ + private void parseOffset(String soqlString) { + Matcher offsetMatcher = OFFSET_PATTERN.matcher(soqlString); + if (!offsetMatcher.find()) { + return; // OFFSET is optional + } + + String offsetStr = offsetMatcher.group(1); + if (String.isNotBlank(offsetStr)) { + this.query.setRowOffset(Integer.valueOf(offsetStr)); + } + } + + /** + * @description Parses the USING SCOPE clause + */ + private void parseScope(String soqlString) { + Matcher scopeMatcher = SCOPE_PATTERN.matcher(soqlString); + if (!scopeMatcher.find()) { + return; // USING SCOPE is optional + } + + String scopeStr = scopeMatcher.group(1)?.toUpperCase(); + Soql.Scope scope = null; + + switch on scopeStr { + when 'EVERYTHING' { + scope = Soql.Scope.EVERYTHING; + } + when 'MINE' { + scope = Soql.Scope.MINE; + } + when 'MINE_AND_MY_GROUPS' { + scope = Soql.Scope.MINE_AND_MY_GROUPS; + } + when 'MY_TERRITORY' { + scope = Soql.Scope.MY_TERRITORY; + } + when 'MY_TEAM_TERRITORY' { + scope = Soql.Scope.MY_TEAM_TERRITORY; + } + when 'TEAM' { + scope = Soql.Scope.TEAM; + } + } + + if (scope != null) { + this.query.setScope(scope); + } + } + + /** + * @description Parses the FOR VIEW/UPDATE/REFERENCE clause + */ + private void parseUsage(String soqlString) { + Matcher usageMatcher = USAGE_PATTERN.matcher(soqlString); + if (!usageMatcher.find()) { + return; // FOR clause is optional + } + + String usageStr = usageMatcher.group(1)?.toUpperCase(); + Soql.Usage usage = null; + + switch on usageStr { + when 'VIEW' { + usage = Soql.Usage.FOR_VIEW; + } + when 'UPDATE' { + usage = Soql.Usage.FOR_UPDATE; + } + when 'REFERENCE' { + usage = Soql.Usage.FOR_REFERENCE; + } + } + + if (usage != null) { + this.query.setUsage(usage); + } + } + + /** + * @description Parses the WITH SECURITY_ENFORCED clause + */ + private void parseSecurity(String soqlString) { + Matcher securityMatcher = SECURITY_PATTERN.matcher(soqlString); + if (securityMatcher.find()) { + this.query.withSecurityEnforced(); + } + } + + /** + * @description Parses the ALL ROWS clause + */ + private void parseAllRows(String soqlString) { + Matcher allRowsMatcher = ALL_ROWS_PATTERN.matcher(soqlString); + if (allRowsMatcher.find()) { + this.query.setUsage(Soql.Usage.ALL_ROWS); + } + } + + /** + * @description Splits a string by comma while respecting parentheses + */ + private List splitByCommaRespectingParentheses(String input) { + List result = new List(); + Integer parenDepth = 0; + Integer lastSplit = 0; + + for (Integer i = 0; i < input.length(); i++) { + String ch = input.substring(i, i + 1); + + if (ch == '(') { + parenDepth++; + } else if (ch == ')') { + parenDepth--; + } else if (ch == ',' && parenDepth == 0) { + result.add(input.substring(lastSplit, i)); + lastSplit = i + 1; + } + } + + // Add the last segment + if (lastSplit < input.length()) { + result.add(input.substring(lastSplit)); + } + + return result; + } + + /** + * @description Gets the SObjectType from a string name using dynamic Apex + */ + private SObjectType getSObjectType(String objectName) { + try { + return Schema.getGlobalDescribe().get(objectName)?.getDescribe()?.getSObjectType(); + } catch (Exception e) { + return null; + } + } + + /** + * @description Exception thrown when SOQL parsing fails + */ + global class ParserException extends Exception { + } + } } diff --git a/source/classes/SoqlTest.cls b/source/classes/SoqlTest.cls index 61e4f3f..3ca37e9 100644 --- a/source/classes/SoqlTest.cls +++ b/source/classes/SoqlTest.cls @@ -1805,6 +1805,500 @@ private class SoqlTest { Test.stopTest(); } + /** + * @description Tests parsing a basic SELECT query with simple field selection + */ + @IsTest + private static void shouldParseBasicSelectFromQuery() { + String soqlString = 'SELECT Id, Name FROM Account'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String expected = 'SELECT Id, Name FROM Account'; + Assert.areEqual(expected, query?.toString(), 'Query string mismatch'); + } + + /** + * @description Tests parsing SELECT query with WHERE clause + */ + @IsTest + private static void shouldParseQueryWithWhereClause() { + String soqlString = 'SELECT Id FROM User WHERE IsActive = true'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String expected = 'SELECT Id FROM User WHERE IsActive = true'; + Assert.areEqual(expected, query?.toString(), 'Query string mismatch'); + } + + /** + * @description Tests parsing SELECT query with bind variables + */ + @IsTest + private static void shouldParseQueryWithBindVariables() { + String soqlString = 'SELECT Id FROM User WHERE Id = :userId'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String expected = 'SELECT Id FROM User WHERE Id = :userId'; + Assert.areEqual(expected, query?.toString(), 'Query string mismatch'); + } + + /** + * @description Tests parsing SELECT query with parent field references + */ + @IsTest + private static void shouldParseQueryWithParentFields() { + String soqlString = 'SELECT Id, Account.Name FROM Opportunity'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String expected = 'SELECT Id, Account.Name FROM Opportunity'; + Assert.areEqual(expected, query?.toString(), 'Query string mismatch'); + } + + /** + * @description Tests parsing SELECT query with multiple level parent field references + */ + @IsTest + private static void shouldParseQueryWithMultiLevelParentFields() { + String soqlString = 'SELECT Id, Account.Owner.Name FROM Opportunity'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String expected = 'SELECT Id, Account.Owner.Name FROM Opportunity'; + Assert.areEqual(expected, query?.toString(), 'Query string mismatch'); + } + + /** + * @description Tests parsing SELECT query with ORDER BY clause + */ + @IsTest + private static void shouldParseQueryWithOrderBy() { + String soqlString = 'SELECT Id FROM User ORDER BY CreatedDate DESC'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String expected = 'SELECT Id FROM User ORDER BY CreatedDate DESC'; + // Note: The builder may add NULLS LAST by default + Assert.isTrue(query?.toString()?.contains('ORDER BY CreatedDate DESC'), 'Query should contain ORDER BY clause'); + } + + /** + * @description Tests parsing SELECT query with ORDER BY and NULLS LAST + */ + @IsTest + private static void shouldParseQueryWithOrderByNullsLast() { + String soqlString = 'SELECT Id FROM User ORDER BY CreatedDate DESC NULLS LAST'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String expected = 'SELECT Id FROM User ORDER BY CreatedDate DESC NULLS LAST'; + Assert.areEqual(expected, query?.toString(), 'Query string mismatch'); + } + + /** + * @description Tests parsing SELECT query with multiple ORDER BY fields + */ + @IsTest + private static void shouldParseQueryWithMultipleOrderByFields() { + String soqlString = 'SELECT Id FROM User ORDER BY Name ASC, CreatedDate DESC'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + Assert.isTrue(query?.toString()?.contains('ORDER BY'), 'Query should contain ORDER BY clause'); + Assert.isTrue(query?.toString()?.contains('Name'), 'Query should order by Name'); + Assert.isTrue(query?.toString()?.contains('CreatedDate'), 'Query should order by CreatedDate'); + } + + /** + * @description Tests parsing SELECT query with LIMIT clause + */ + @IsTest + private static void shouldParseQueryWithLimit() { + String soqlString = 'SELECT Id FROM User LIMIT 10'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String expected = 'SELECT Id FROM User LIMIT 10'; + Assert.areEqual(expected, query?.toString(), 'Query string mismatch'); + } + + /** + * @description Tests parsing SELECT query with LIMIT and OFFSET + */ + @IsTest + private static void shouldParseQueryWithLimitAndOffset() { + String soqlString = 'SELECT Id FROM User LIMIT 10 OFFSET 5'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String expected = 'SELECT Id FROM User LIMIT 10 OFFSET 5'; + Assert.areEqual(expected, query?.toString(), 'Query string mismatch'); + } + + /** + * @description Tests parsing SELECT query with WHERE and AND conditions + */ + @IsTest + private static void shouldParseQueryWithAndConditions() { + String soqlString = 'SELECT Id FROM User WHERE IsActive = true AND Name = \'Test\''; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String result = query?.toString(); + Assert.isTrue(result?.contains('WHERE'), 'Query should contain WHERE clause'); + Assert.isTrue(result?.contains('IsActive = true'), 'Query should filter by IsActive'); + Assert.isTrue(result?.contains('Name = \'Test\''), 'Query should filter by Name'); + } + + /** + * @description Tests parsing SELECT query with WHERE and OR conditions + */ + @IsTest + private static void shouldParseQueryWithOrConditions() { + String soqlString = 'SELECT Id FROM User WHERE IsActive = true OR Name = \'Test\''; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String result = query?.toString(); + Assert.isTrue(result?.contains('WHERE'), 'Query should contain WHERE clause'); + Assert.isTrue(result?.contains('IsActive = true'), 'Query should filter by IsActive'); + Assert.isTrue(result?.contains('Name = \'Test\''), 'Query should filter by Name'); + } + + /** + * @description Tests parsing SELECT query with date literals + */ + @IsTest + private static void shouldParseQueryWithDateLiterals() { + String soqlString = 'SELECT Id FROM Account WHERE CreatedDate = TODAY'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String result = query?.toString(); + Assert.isTrue(result?.contains('WHERE'), 'Query should contain WHERE clause'); + Assert.isTrue(result?.contains('CreatedDate'), 'Query should filter by CreatedDate'); + } + + /** + * @description Tests parsing SELECT query with COUNT aggregate function + */ + @IsTest + private static void shouldParseQueryWithCountFunction() { + String soqlString = 'SELECT COUNT(Id) FROM User'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String expected = 'SELECT COUNT(Id) FROM User'; + Assert.areEqual(expected, query?.toString(), 'Query string mismatch'); + } + + /** + * @description Tests parsing SELECT query with multiple aggregate functions + */ + @IsTest + private static void shouldParseQueryWithMultipleAggregateFunctions() { + String soqlString = 'SELECT COUNT(Id), AVG(NumberOfEmployees), SUM(NumberOfEmployees) FROM Account'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String result = query?.toString(); + Assert.isTrue(result?.contains('COUNT(Id)'), 'Query should contain COUNT function'); + Assert.isTrue(result?.contains('AVG(NumberOfEmployees)'), 'Query should contain AVG function'); + Assert.isTrue(result?.contains('SUM(NumberOfEmployees)'), 'Query should contain SUM function'); + } + + /** + * @description Tests parsing SELECT query with GROUP BY clause + */ + @IsTest + private static void shouldParseQueryWithGroupBy() { + String soqlString = 'SELECT ProfileId FROM User GROUP BY ProfileId'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String expected = 'SELECT ProfileId FROM User GROUP BY ProfileId'; + Assert.areEqual(expected, query?.toString(), 'Query string mismatch'); + } + + /** + * @description Tests parsing SELECT query with parent field in GROUP BY + */ + @IsTest + private static void shouldParseQueryWithParentFieldGroupBy() { + String soqlString = 'SELECT Owner.ProfileId FROM Account GROUP BY Owner.ProfileId'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String result = query?.toString(); + Assert.isTrue(result?.contains('GROUP BY'), 'Query should contain GROUP BY clause'); + Assert.isTrue(result?.contains('Owner.ProfileId'), 'Query should group by Owner.ProfileId'); + } + + /** + * @description Tests parsing SELECT query with USING SCOPE clause + */ + @IsTest + private static void shouldParseQueryWithUsingScope() { + String soqlString = 'SELECT Id FROM User USING SCOPE EVERYTHING'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String expected = 'SELECT Id FROM User USING SCOPE EVERYTHING'; + Assert.areEqual(expected, query?.toString(), 'Query string mismatch'); + } + + /** + * @description Tests parsing SELECT query with FOR VIEW clause + */ + @IsTest + private static void shouldParseQueryWithForView() { + String soqlString = 'SELECT Id FROM User FOR VIEW'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String expected = 'SELECT Id FROM User FOR VIEW'; + Assert.areEqual(expected, query?.toString(), 'Query string mismatch'); + } + + /** + * @description Tests parsing SELECT query with WITH SECURITY_ENFORCED + */ + @IsTest + private static void shouldParseQueryWithSecurityEnforced() { + String soqlString = 'SELECT Id FROM User WITH SECURITY_ENFORCED'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String expected = 'SELECT Id FROM User WITH SECURITY_ENFORCED'; + Assert.areEqual(expected, query?.toString(), 'Query string mismatch'); + } + + /** + * @description Tests parsing a complex SELECT query with multiple clauses + */ + @IsTest + private static void shouldParseComplexQuery() { + String soqlString = 'SELECT Id, Name FROM Account WHERE CreatedDate = TODAY ORDER BY Name ASC LIMIT 10'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String result = query?.toString(); + Assert.isTrue(result?.contains('SELECT Id, Name'), 'Query should select Id and Name'); + Assert.isTrue(result?.contains('FROM Account'), 'Query should query Account'); + Assert.isTrue(result?.contains('WHERE'), 'Query should have WHERE clause'); + Assert.isTrue(result?.contains('ORDER BY'), 'Query should have ORDER BY clause'); + Assert.isTrue(result?.contains('LIMIT 10'), 'Query should have LIMIT 10'); + } + + /** + * @description Tests that parser throws exception for invalid SOQL (missing FROM) + */ + @IsTest + private static void shouldThrowExceptionForMissingFrom() { + String soqlString = 'SELECT Id, Name'; + + Test.startTest(); + try { + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Assert.fail('Should have thrown ParserException for missing FROM clause'); + } catch (Soql.Parser.ParserException e) { + Assert.isTrue(e?.getMessage()?.contains('FROM'), 'Exception should mention FROM clause'); + } + Test.stopTest(); + } + + /** + * @description Tests that parser throws exception for invalid SOQL (missing SELECT) + */ + @IsTest + private static void shouldThrowExceptionForMissingSelect() { + String soqlString = 'FROM Account'; + + Test.startTest(); + try { + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Assert.fail('Should have thrown ParserException for missing SELECT clause'); + } catch (Soql.Parser.ParserException e) { + Assert.isTrue(e?.getMessage()?.contains('SELECT'), 'Exception should mention SELECT clause'); + } + Test.stopTest(); + } + + /** + * @description Tests that parser throws exception for blank query + */ + @IsTest + private static void shouldThrowExceptionForBlankQuery() { + String soqlString = ''; + + Test.startTest(); + try { + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Assert.fail('Should have thrown ParserException for blank query'); + } catch (Soql.Parser.ParserException e) { + Assert.isTrue(e?.getMessage()?.contains('blank'), 'Exception should mention blank query'); + } + Test.stopTest(); + } + + /** + * @description Tests that parser throws exception for invalid SObject type + */ + @IsTest + private static void shouldThrowExceptionForInvalidSObjectType() { + String soqlString = 'SELECT Id FROM InvalidObject123'; + + Test.startTest(); + try { + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Assert.fail('Should have thrown ParserException for invalid SObject type'); + } catch (Soql.Parser.ParserException e) { + Assert.isTrue( + e?.getMessage()?.contains('Invalid SObject') || e?.getMessage()?.contains('InvalidObject123'), + 'Exception should mention invalid SObject: ' + e?.getMessage() + ); + } + Test.stopTest(); + } + + /** + * @description Tests parsing query with string value containing quotes + */ + @IsTest + private static void shouldParseQueryWithQuotedStringValue() { + String soqlString = 'SELECT Id FROM Account WHERE Name = \'Test Account\''; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String result = query?.toString(); + Assert.isTrue(result?.contains('WHERE'), 'Query should contain WHERE clause'); + Assert.isTrue(result?.contains('Name'), 'Query should filter by Name'); + } + + /** + * @description Tests parsing query with numeric value + */ + @IsTest + private static void shouldParseQueryWithNumericValue() { + String soqlString = 'SELECT Id FROM Account WHERE NumberOfEmployees > 100'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + Test.stopTest(); + + String result = query?.toString(); + Assert.isTrue(result?.contains('WHERE'), 'Query should contain WHERE clause'); + Assert.isTrue(result?.contains('NumberOfEmployees'), 'Query should filter by NumberOfEmployees'); + } + + /** + * @description Tests parsing query with different comparison operators + */ + @IsTest + private static void shouldParseQueryWithDifferentOperators() { + Test.startTest(); + + // Test EQUALS + Soql query1 = DatabaseLayer.Soql.newQuery('SELECT Id FROM Account WHERE Name = \'Test\''); + Assert.isTrue(query1?.toString()?.contains('WHERE'), 'EQUALS query should have WHERE'); + + // Test NOT_EQUALS + Soql query2 = DatabaseLayer.Soql.newQuery('SELECT Id FROM Account WHERE Name != \'Test\''); + Assert.isTrue(query2?.toString()?.contains('WHERE'), 'NOT_EQUALS query should have WHERE'); + + // Test LESS_THAN + Soql query3 = DatabaseLayer.Soql.newQuery('SELECT Id FROM Account WHERE NumberOfEmployees < 100'); + Assert.isTrue(query3?.toString()?.contains('WHERE'), 'LESS_THAN query should have WHERE'); + + // Test GREATER_THAN + Soql query4 = DatabaseLayer.Soql.newQuery('SELECT Id FROM Account WHERE NumberOfEmployees > 100'); + Assert.isTrue(query4?.toString()?.contains('WHERE'), 'GREATER_THAN query should have WHERE'); + + Test.stopTest(); + } + + /** + * @description Tests that parsed query can be executed + */ + @IsTest + private static void shouldExecuteParsedQuery() { + String soqlString = 'SELECT Id FROM User LIMIT 1'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + List users = query?.query(); + Test.stopTest(); + + Assert.isNotNull(users, 'Query should return results'); + Assert.isTrue(users?.size() <= 1, 'Query should respect LIMIT 1'); + } + + /** + * @description Tests that parsed query with bind variables can be executed + */ + @IsTest + private static void shouldExecuteParsedQueryWithBinds() { + String soqlString = 'SELECT Id FROM User WHERE Id = :userId LIMIT 1'; + + Test.startTest(); + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + // Add the bind value + User currentUser = [SELECT Id FROM User WHERE Id = :UserInfo.getUserId() LIMIT 1]; + query?.addBind('userId', currentUser?.Id); + List users = query?.query(); + Test.stopTest(); + + Assert.isNotNull(users, 'Query should return results'); + Assert.areEqual(1, users?.size(), 'Query should return the current user'); + Assert.areEqual(currentUser?.Id, users[0]?.Id, 'Should return the correct user'); + } + // **** INNER **** // private class SampleWrapper { public Integer numRecords; diff --git a/wiki/The-DatabaseLayer.SoqlProvider-Class.md b/wiki/The-DatabaseLayer.SoqlProvider-Class.md index b903e92..fca1201 100644 --- a/wiki/The-DatabaseLayer.SoqlProvider-Class.md +++ b/wiki/The-DatabaseLayer.SoqlProvider-Class.md @@ -21,3 +21,18 @@ Assert.isInstanceofType(query2, MockSoql.class, 'Not a Mock'); Generates a new `Soql` query using the given `SObjectType` as the FROM object. - `Soql newQuery(SObjectType objectType)` + +Generates a new `Soql` query from a SOQL query string. Parses the provided SOQL string and constructs a Soql.Builder instance with the parsed components. + +- `Soql newQuery(String soqlString)` + - **soqlString** - The SOQL query string to parse (e.g., 'SELECT Id, Name FROM Account WHERE CreatedDate = TODAY LIMIT 10') + - **Returns** - A configured SOQL builder instance populated from the parsed query string + - **Throws** - `Soql.Parser.ParserException` if the query string is invalid or cannot be parsed + +**Example:** + +```apex +String queryStr = 'SELECT Id, Name FROM Account WHERE CreatedDate = TODAY ORDER BY Name ASC LIMIT 10'; +Soql query = DatabaseLayer.Soql.newQuery(queryStr); +List accounts = query.query(); +``` diff --git a/wiki/The-Soql.Parser-Class.md b/wiki/The-Soql.Parser-Class.md new file mode 100644 index 0000000..f42af50 --- /dev/null +++ b/wiki/The-Soql.Parser-Class.md @@ -0,0 +1,76 @@ +The `Soql.Parser` class is responsible for parsing SOQL query strings and converting them into `Soql.Builder` instances. This class provides the core functionality that enables the string-based SOQL query construction feature. + +This inner class of `Soql` uses regex-based tokenization to parse most common SOQL patterns and constructs the appropriate builder methods to recreate the query. + +## Supported SOQL Features + +The parser supports parsing the following SOQL clauses and features: + +- **SELECT** - Simple field selections and parent field references +- **FROM** - Target SObject specification +- **WHERE** - Conditions with AND/OR logic +- **ORDER BY** - Sorting with ASC/DESC and NULLS FIRST/LAST +- **GROUP BY** - Grouping with single and multiple fields +- **LIMIT** - Row limiting +- **OFFSET** - Result offset +- **USING SCOPE** - Record visibility scoping +- **FOR VIEW/UPDATE/REFERENCE** - Record locking +- **WITH SECURITY_ENFORCED** - Field-level security enforcement +- **Aggregate functions** - COUNT, AVG, SUM, MIN, MAX +- **Bind variables** - :variable syntax +- **Date literals** - TODAY, YESTERDAY, TOMORROW +- **Comparison operators** - =, !=, <, >, <=, >=, LIKE, IN, etc. + +## Limitations + +Current implementation limitations include: + +- Complex nested WHERE logic with parentheses not yet supported +- HAVING clause parsing not yet implemented +- Subqueries and TYPEOF polymorphic queries not supported +- These features may be added in future iterations + +--- + +## Methods + +### `fromString` + +Creates a new `Soql.Builder` instance by parsing the provided SOQL query string. + +- `static Soql.Builder fromString(String soqlString, DatabaseLayer factory)` + - **soqlString** - The SOQL query string to parse + - **factory** - The DatabaseLayer instance to use for creating the Soql object + - **Returns** - A configured `Soql.Builder` instance populated from the parsed query string + - **Throws** - `Soql.Parser.ParserException` if the query string is invalid or cannot be parsed + +**Example:** + +```apex +String soqlString = 'SELECT Id, Name FROM Account WHERE Type = \'Customer\' ORDER BY Name LIMIT 10'; +Soql.Builder builder = Soql.Parser.fromString(soqlString, DatabaseLayer.INSTANCE); +Soql query = builder.toSoql(); +List accounts = query.query(); +``` + +> :information_source: **Note:** This method is typically called internally by [`DatabaseLayer.SoqlProvider.newQuery(String)`](./The-DatabaseLayer.SoqlProvider-Class#newquery) rather than being called directly. + +--- + +## Inner Classes + +### `ParserException` + +Exception thrown when SOQL string parsing fails due to invalid syntax or unsupported features. + +**Extends:** `Exception` + +**Usage:** + +```apex +try { + Soql query = DatabaseLayer.Soql.newQuery('INVALID SOQL SYNTAX'); +} catch (Soql.Parser.ParserException ex) { + System.debug('Failed to parse SOQL: ' + ex.getMessage()); +} +``` diff --git a/wiki/The-Soql.Parser.ParserException-Class.md b/wiki/The-Soql.Parser.ParserException-Class.md new file mode 100644 index 0000000..8a67421 --- /dev/null +++ b/wiki/The-Soql.Parser.ParserException-Class.md @@ -0,0 +1,67 @@ +The `Soql.Parser.ParserException` class is a custom exception thrown by the [`Soql.Parser`](./The-Soql.Parser-Class) when SOQL string parsing fails due to invalid syntax or unsupported features. + +This exception extends the standard Apex `Exception` class and provides specific error information when SOQL parsing encounters problems. + +**Extends:** `Exception` + +## Usage + +This exception is thrown automatically by the parser when it encounters: + +- Invalid SOQL syntax +- Unsupported SOQL features (see [Parser limitations](./The-Soql.Parser-Class#limitations)) +- Malformed query strings +- Missing required clauses + +**Example:** + +```apex +try { + // This will throw a ParserException due to invalid syntax + Soql query = DatabaseLayer.Soql.newQuery('INVALID SOQL SYNTAX'); +} catch (Soql.Parser.ParserException ex) { + System.debug('Failed to parse SOQL: ' + ex.getMessage()); + // Handle the parsing error appropriately +} +``` + +## Common Scenarios + +The `ParserException` is typically thrown in these scenarios: + +1. **Invalid SOQL Syntax:** + + ```apex + // Missing FROM clause + String invalidSoql = 'SELECT Id, Name'; + ``` + +2. **Unsupported Features:** + + ```apex + // Complex nested parentheses (not yet supported) + String complexSoql = 'SELECT Id FROM Account WHERE (Type = \'A\' AND (Status = \'Active\' OR Status = \'Pending\'))'; + ``` + +3. **Malformed Field References:** + ```apex + // Invalid field syntax + String malformedSoql = 'SELECT Id,, Name FROM Account'; + ``` + +## Error Handling Best Practices + +When working with SOQL string parsing, consider implementing proper error handling: + +```apex +public static List safeQuery(String soqlString) { + try { + Soql query = DatabaseLayer.Soql.newQuery(soqlString); + return query.query(); + } catch (Soql.Parser.ParserException ex) { + System.debug(LoggingLevel.ERROR, 'SOQL parsing failed: ' + ex.getMessage()); + // Fallback to a safe default query or return empty list + return new List(); + } +} +``` diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md index c9e90d8..cf9e378 100644 --- a/wiki/_Sidebar.md +++ b/wiki/_Sidebar.md @@ -82,6 +82,8 @@ - [Soql.Operation](./The-Soql.Operation-Enum) - [Soql.Operator](./The-Soql.Operator-Class) - [Soql.ParentField](./The-Soql.ParentField-Class) + - [Soql.Parser](./The-Soql.Parser-Class) + - [Soql.Parser.ParserException](./The-Soql.Parser.ParserException-Class) - [Soql.PreAndPostProcessor](./The-Soql.PreAndPostProcessor-Interface) - [Soql.QueryLocator](./The-Soql.QueryLocator-Class) - [Soql.Request](./The-Soql.Request-Class)