Name

sn_hr_core.hr_Criteria

Description

Util for evaluating HR criteria and underlying conditions

Script

var hr_Criteria = Class.create();

hr_Criteria.prototype = {
  type: 'hr_Criteria',
  initialize: function() {
  	this.userTables = new GlideTableHierarchy("sys_user").getAllExtensions();
  	this.optimizeCriteria = gs.getProperty('sn_hr_core.hr_criteria.optimize', 'false') === 'true';
  	this.hrCriteriaCacheName = 'HRCriteria'; // this is OOB data in sys_scoped_cache table
  },

  /* Return the count of users meeting @param hrCriteriaId conditions
   *
   * @param hrCriteriaId String sys_id of an HR Criteria
   * @return number Count of the users that meet @param hrCriteriaId conditions
   */
  getUserCountForHRCriteria: function(hrCriteriaId) {
  	var userList = this.getUsersForHRCriteria(hrCriteriaId, true);
  	return userList.length;
  },

  /* Return a list of users meeting any @param hrCriteriaId
   *
   * @param hrCriteriaId String Comma separated list of HR Criteria sys_id's
   * @param isCount [boolean] (optional) Whether this API is being used to get a count
   * @return Array List of sys_id's of Users that meet @param hrCriteriaId's conditions
   */
  getUsersForHRCriteria: function(hrCriteriaId, isCount) {
  	var userList = [];
  	var finalUserMap = {};
  	
  	// Get the maximum number of aggregates allowed, and disable optimization if below 50000
  	var maxAggregates = parseInt(gs.getProperty('glide.db.max.aggregates', 100000));
  	if (!maxAggregates || isNaN(maxAggregates) || maxAggregates < 50000)
  		this.optimizeCriteria = false;

  	// Ensure criteria is active and exists (table is only required if there is a user_column)
  	var criteriaGr = new GlideRecord("sn_hr_core_criteria");
  	criteriaGr.addQuery("sys_id", "IN", hrCriteriaId);
  	criteriaGr.addActiveQuery();
  	criteriaGr.query();
  	while (criteriaGr.next()) {
  		// Ensure that if there is a user_column then it and the table are valid
  		var criteriaTable = criteriaGr.getValue('table'); 
  		var criteriaUserColumn = criteriaGr.getValue('user_column');
  		var criteriaTableGr;
  		if (criteriaTable) {
  			criteriaTableGr = new GlideRecord(criteriaTable);
  			if (criteriaUserColumn && (!criteriaTableGr.isValid() || !criteriaTableGr.isValidField(criteriaUserColumn)))
  				continue;
  		}

  		// Build object of all conditions for given criteria, e.g., {'TABLE_NAME': { 'USER_COLUMN': ['condition', ...]}
  		var conditions = {};
  		var conditionGr = new GlideRecord('sn_hr_core_condition');
  		conditionGr.addActiveQuery();
  		conditionGr.addNotNullQuery('table');
  		conditionGr.addNotNullQuery('condition');
  		var m2mQc = conditionGr.addJoinQuery('sn_hr_core_m2m_condition_criteria', 'sys_id', 'hr_condition');
  		m2mQc.addCondition('hr_criteria', criteriaGr.getUniqueValue());
  		m2mQc.addCondition('hr_criteria.active', true);
  		conditionGr.query();
  		var queryCount = 0;
  		var invalidCondition = false;
  		while (conditionGr.next()) {
  			var conditionTable = conditionGr.getValue('table');
  			if (!conditions.hasOwnProperty(conditionTable))
  				conditions[conditionTable] = {};

  			// Determine user column to use
  			var userColumn;
  			if (!gs.nil(conditionGr.user_column))
  				userColumn = conditionGr.getElement('user_column').toString();
  			else if (this.userTables.indexOf(conditionTable) > -1)
  				userColumn = 'sys_id';
  			else {
  				invalidCondition = true;
  				break;
  			}

  			if (!conditions[conditionTable].hasOwnProperty(userColumn)) {
  				conditions[conditionTable][userColumn] = [];
  				queryCount++; // Count total queries, where a query may include multiple conditions
  			}
  			conditions[conditionTable][userColumn].push(conditionGr.getElement('condition').toString().trim());
  		}
  		
  		// Empty or invalid condition column here should skip current criteria
  		if (invalidCondition) {
  			gs.info(this.type + ": Invalid user_column for sn_hr_core_condition " + conditionGr.getUniqueValue());
  			continue;
  		}
  		
  		if (this.optimizeCriteria) {
  			// TODO (Future optimization) Can join conditions if other table is an extension of sys_user and using sys_id as the user column
  			// Combine conditions for the sys_user table with a non-sys_user table conditions, where applicable
  			// Look for a non sys_user table and column to join to
  			var joinTable;
  			var joinColumn;
  			for (var t in conditions) {
  				for (var c in conditions[t]) {
  					if ((t && t != 'sys_user') && c && (c != 'sys_id')) {
  						joinTable = t;
  						joinColumn = c;
  						break;
  					}
  				}
  				if (joinTable && joinColumn)
  					break;
  			}
  			
  			// Check that there is at least one sys_user condition to join
  			// Combine sys_user table only when the user column is 'sys_id' to prevent having to query for each user getting the dot walked column
  			if (joinTable && joinColumn && conditions.hasOwnProperty('sys_user') && conditions['sys_user'].hasOwnProperty('sys_id')) {
  				// Join sys_user conditions to join table conditions
  				for (var j in conditions['sys_user']['sys_id'])
  					conditions[joinTable][joinColumn].push(this._getUserQuery(conditions['sys_user']['sys_id'][j], joinColumn));

  				// Only remove sys_user conditions that were joined
  				if (Object.keys(conditions['sys_user']).length == 1)
  					delete conditions['sys_user'];
  				else
  					delete conditions['sys_user']['sys_id'];

  				queryCount--;
  			}
  		}

  		var conditionQueries = [];
  		// Build array of queries
  		for (var tableName in conditions) {
  			for (var columnName in conditions[tableName]) {
  				if (this.optimizeCriteria || conditions[tableName][columnName].filter(function(item) { return item.indexOf('^NQ') > -1; }).length == 0) {
  					conditionQueries.push({
  						'table': tableName + '',
  						'column': columnName + '',
  						'conditions': conditions[tableName + ''][columnName + '']
  					});
  				} else { // GlideRecord may not work when adding together conditions containing an ^NQ
  					for (var l = 0; l < conditions[tableName + ''][columnName + ''].length; l++) {
  						conditionQueries.push({
  							'table': tableName + '',
  							'column': columnName + '',
  							'conditions': [conditions[tableName + ''][columnName + ''][l] + '']
  						});
  					}
  				}
  			}
  		}
  		queryCount = conditionQueries.length;

  		// Build object of users that meet criteriaGr's conditions
  		var userQueriesMet = {};
  		for (var m = 0; m < conditionQueries.length; m++) {
  			var table = conditionQueries[m].table;
  			var column = conditionQueries[m].column;
  			var conditionArray = conditionQueries[m].conditions;
  			
  			// Vars used for pagination of GlideAggregate, if needed
  			var count = 0;
  			var total = 1;
  			while (count < total) {
  				// Condition for single criteria and single condition where we do not need to intersect query results and can use aggregate count instead 
  				var isSingleQueryCount = isCount && this.optimizeCriteria && criteriaGr.getRowCount() == 1 
  				&& conditionQueries.length == 1 && (!criteriaUserColumn || !criteriaTable);

  				var conditionTableG;
  				// Use GlideAggregate to reduce the amount of data returned for each record queried
  				// Example: If there are 300 fields on a table, then GlideAggregate is quicker than GlideRecord for iterating over records
  				if (this.optimizeCriteria) {
  					conditionTableG = new GlideAggregate(table);
  					if (isSingleQueryCount) {
  						conditionTableG.setGroup(false);
  						conditionTableG.addAggregate('COUNT(DISTINCT', column);
  					} else
  						conditionTableG.groupBy(column);
  				} else 
  					conditionTableG = new GlideRecord(table);

  				conditionTableG.addNotNullQuery(column);
  				// Allow multiple conditions in same query
  				for (var i = 0; i < conditionArray.length; i++)
  					conditionTableG.addEncodedQuery(conditionArray[i]);

  				if (this.optimizeCriteria && !isSingleQueryCount)
  					conditionTableG.chooseWindow(count, count + maxAggregates);
  				conditionTableG.query();

  				// If this is a single query where only the count is needed, then return the aggregate count
  				if (this.optimizeCriteria && isSingleQueryCount) {
  					conditionTableG.next();
  					userList.length = conditionTableG.getAggregate('COUNT(DISTINCT', column) || 0;
  					return userList;
  				} else {
  					// Avoid calling getRowCount each iteration
  					if (count == 0)
  						total = conditionTableG.getRowCount();
  					// Redundant check in case the total does not match the count, but there are no more records
  					if (!conditionTableG.hasNext())
  						break;
  					while (conditionTableG.next()) {
  						count++;
  						var userId = conditionTableG.getValue(column);
  						if (!userQueriesMet.hasOwnProperty(userId))
  							userQueriesMet[userId] = {};
  						// Use key=true rather than a count to prevent double counting a user in a query
  						userQueriesMet[userId][table + 'm' + column + '-' + m] = true;
  					}
  				}
  			}
  		}

  		// Add users who meet every condition to a list
  		for (var userSysId in userQueriesMet) {
  			var queriesMet = Object.keys(userQueriesMet[userSysId]).length;
  			if (queriesMet != queryCount)
  				continue;

  			// Only check criteria record exists if criteria table and user_column are provided
  			if (!criteriaUserColumn || !criteriaTableGr || criteriaTableGr.get(criteriaUserColumn, userSysId))
  				finalUserMap[userSysId] = true;
  		}
  	}

  	for (var finalUserSysId in finalUserMap)
  		userList.push(finalUserSysId);

  	return userList;
  },
  
  /*
   * Adapt a sys_user encoded query to work on a non-sys_user table using the @param userColumn
   * Example: For @param encodedQuery of "active=true^ORmarital_status=single"
   *     return "user.active=true^ORuser.marital_status=single" for sn_hr_core_profile
   *     return "active=true^ORmarital_status=single" for sys_user
   * @param encodedQuery (String) Encoded query to adapt
   * @param userColumn (String) User field name to use to adapt encoded query
   * @return (String) Parsed encoded query
   */
  _getUserQuery: function(encodedQuery, userColumn) {
  	if (!encodedQuery || !userColumn || userColumn == 'sys_id')
  		return encodedQuery;

  	if (encodedQuery.endsWith('^EQ'))
  		encodedQuery = encodedQuery.substring(0, encodedQuery.length - 3);
  	
  	var queries = encodedQuery.split('^NQ');
  	var queriesRet = [];
  	for (var i = 0; i < queries.length; i++) {
  		var orConditions = queries[i].split('^OR');
  		var orConditionsRet = [];
  		for (var j = 0; j < orConditions.length; j++) {
  			var andConditions = orConditions[j].split('^');
  			var andConditionsRet = [];
  			for (var k = 0; k < andConditions.length; k++)
  				andConditionsRet.push(userColumn + '.' + andConditions[k]);
  			if (andConditionsRet.length > 0)
  				orConditionsRet.push(andConditionsRet.join('^'));
  		}
  		if (orConditionsRet.length > 0)
  			queriesRet.push(orConditionsRet.join('^OR'));
  	}

  	return queriesRet.join('^NQ');
  },

  /* Evaluates the HR Criteria specified by @param criteriaId.  If @param criteriaId is not specified, then return true.
   *
   * @param criteriaId String sys_id of a sn_hr_core_criteria
   * @param userId (optional) String sys_id of a sys_user or extension, uses current user if not provided
   * @param caseId (optional) String LE case sys_id to add the right survey instance to hr_condition
   * @param simulation (optional) Boolean
   * @param useCache (optional) Boolean Try to use scoped cache to retrieve / store result. Not all audience types support caching.
   * @return boolean,
   *     For the criteria to evaluate the following must be true:
   *        1. The Criteria record must be active
   *        2. There must be at least one active related HR Condition with a table and condition
   *        3. All applicable conditions must evaluate to true (have at least one result)
   *
   *     Inactive or empty Conditions are ignored
   */
  evaluateById : function(criteriaId, userId, simulation, caseId, useCache) {
  	if (gs.nil(criteriaId))
  		return true;

  	userId = userId || gs.getUserID();

  	// Ensure criteria exists (table is only required if there is a user_column)
  	var criteriaGr = new GlideRecord("sn_hr_core_criteria");
  	if (!criteriaGr.get(criteriaId))
  		return false;

  	// Ensure that if there is a user_column then it and the table are valid
  	if (criteriaGr.table) {
  		var criteriaUserColumn = criteriaGr.user_column;
  		var criteriaTableGr = new GlideRecord(criteriaGr.table);
  		if (criteriaUserColumn && (!criteriaTableGr.isValid() || !criteriaTableGr.isValidField(criteriaUserColumn)))
  			return false;
  		
  		// Check if a record exists in the criteria's table, similar to @function getUsersForHRCriteria
  		if (criteriaUserColumn && !criteriaTableGr.get(criteriaUserColumn, userId))
  			return false;
  	}
  	
  	// Value is in minutes, and TTL of zero turns off caching
  	var ttl = parseInt(gs.getProperty('sn_hr_core.cache.hrcriteria.ttl', 0));
  	if (ttl == 0 || caseId)
  		useCache = false;
  	
  	if (useCache) {
  		var resultFromCache = this._checkCacheForResult(userId, criteriaId, ttl);
  		if (resultFromCache != null)
  			return resultFromCache;
  	}

  	var grCondition = new GlideRecord("sn_hr_core_condition");
  	grCondition.addQuery("active", true);
  	grCondition.addNotNullQuery("condition");
  	grCondition.addNotNullQuery("table");
  	if (simulation)
  		grCondition.addQuery("table", "sn_hr_core_profile").addOrCondition("table", "sys_user");
  	var qc = grCondition.addJoinQuery("sn_hr_core_m2m_condition_criteria");
  	qc.addCondition("hr_criteria", criteriaId);
  	qc.addCondition("hr_criteria.active", true);
  	grCondition.query();
  	
  	if (!grCondition.hasNext())
  		return !!simulation;
  	
  	var result = true;
  	while (grCondition.next()) {
  		var hrCriteriaPrivate = new hr_Criteria_Private();
  		var cond = hrCriteriaPrivate._addUserToCondition(grCondition, userId);
  		if (grCondition.employee_form)
  			cond = hrCriteriaPrivate._addSurveyInstance(grCondition, userId, caseId);
  		
  		if (!(hrCriteriaPrivate._meetsHrCondition(grCondition.table, cond))) {
  			result = false;
  			break;
  		}
  	}

  	if (useCache)
  		this._saveResultToCache(userId, criteriaId, result);

  	return result;
  },
  
  /* Check if the HR Criteria cache contains the result for the given userId and criteriaId, and if it is still valid 
   * based upon the time to live.
   *
   * @param userId String sys_id of a sys_user or extension
   * @param criteriaId String sys_id of a sn_hr_core_criteria
   * @param ttl int The time to live in minutes for entries in the cache; used to evict for the criteria id within a userId key.
   * @return boolean The cached value, or null if not found or expired.
   */
  _checkCacheForResult: function(userId, criteriaId, ttl) {
  	var value = sn_scoped_cache.ScopedCacheManager.get(this.hrCriteriaCacheName, userId);
  	if (!value)
  		return null;
  	
  	var userCachedResults = JSON.parse(value);
  	if (userCachedResults) {
  		var resultByCriteriaId = userCachedResults[criteriaId];
  		if (resultByCriteriaId) {
  			ttl = ttl * 60 * 1000; // convert ttl from minutes to milliseconds to match getNumericValue from GlideDateTime
  			// see if the cache entry should be evicted based upon the Time to Live from the system property
  			var goodThru = ttl + parseInt(resultByCriteriaId.lastComputed);
  			if (new GlideDateTime().getNumericValue() < goodThru)
  				return resultByCriteriaId.result;
  			else {
  				delete userCachedResults[criteriaId];
  				sn_scoped_cache.ScopedCacheManager.put(this.hrCriteriaCacheName, userId, JSON.stringify(userCachedResults));
  			}
  		}
  	}
  	return null;
  },

  /* Save the result for a userId and criteriaId to the HR Criteria cache.
   *
   * @param userId String sys_id of a sys_user or extension
   * @param criteriaId String sys_id of a sn_hr_core_criteria
   * @param result boolean The result for the given userId and criteriaId
   */
  _saveResultToCache: function(userId, criteriaId, result) {
  	var value = sn_scoped_cache.ScopedCacheManager.get(this.hrCriteriaCacheName, userId);
  	var userCachedResults;
  	if (value)
  		userCachedResults = JSON.parse(value);
  	else
  		userCachedResults = {};

  	var answer = {};
  	answer.lastComputed = new GlideDateTime().getNumericValue();
  	answer.result = result;
  	userCachedResults[criteriaId] = answer;

  	sn_scoped_cache.ScopedCacheManager.put(this.hrCriteriaCacheName, userId, JSON.stringify(userCachedResults));
  },
};

Sys ID

e9a507e4532312003585c3c606dc3455

Offical Documentation

Official Docs: