|
| 1 | +import type { Comparator } from '../types/scim-comparator.type'; |
| 2 | +import type { LogicalOperator } from '../types/scim-logical-operator.type'; |
| 3 | + |
| 4 | +import { ScimNode } from '../types/scim-node.type'; |
| 5 | + |
| 6 | + |
| 7 | +export function scimToMongo(scim: string): any { |
| 8 | + if (!scim) { |
| 9 | + return {}; |
| 10 | + } |
| 11 | + const tokens = tokenize(scim); |
| 12 | + const ast = parseTokens(tokens); |
| 13 | + return transformAstToMongo(ast); |
| 14 | +} |
| 15 | + |
| 16 | +/** Escapes Regex Chars */ |
| 17 | +function escapeRegex(input: string): string { |
| 18 | + return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 19 | +} |
| 20 | + |
| 21 | +/** Flattens consecutive logical operators of the same type to avoid nested structures */ |
| 22 | +function flattenLogicalOperator(node: ScimNode, targetOperator: LogicalOperator): ScimNode[] { |
| 23 | + if (!('operator' in node)) { |
| 24 | + return [node]; |
| 25 | + } |
| 26 | + |
| 27 | + if (node.operator !== targetOperator) { |
| 28 | + return [node]; |
| 29 | + } |
| 30 | + |
| 31 | + const leftConditions = flattenLogicalOperator(node.left, targetOperator); |
| 32 | + const rightConditions = flattenLogicalOperator(node.right, targetOperator); |
| 33 | + |
| 34 | + return [...leftConditions, ...rightConditions]; |
| 35 | +} |
| 36 | + |
| 37 | +/** Parses tokenized SCIM filter into an Abstract Syntax Tree (AST) */ |
| 38 | +function parseTokens(tokens: string[]): ScimNode { |
| 39 | + let pos = 0; |
| 40 | + |
| 41 | + /** Parses a full logical expression (e.g., A and B or C) */ |
| 42 | + function parseExpression(): ScimNode { |
| 43 | + let left = parseTerm(); |
| 44 | + while (tokens[pos] && /^(and|or)$/i.test(tokens[pos])) { |
| 45 | + const op: LogicalOperator = tokens[pos++].toLowerCase() as LogicalOperator; |
| 46 | + const right = parseTerm(); |
| 47 | + left = { left, operator: op, right }; |
| 48 | + } |
| 49 | + return left; |
| 50 | + } |
| 51 | + |
| 52 | + /** Parses a single term: either a nested expression, array filter, or condition */ |
| 53 | + function parseTerm(): ScimNode { |
| 54 | + if (tokens[pos] === '(') { // Start of a nested filter |
| 55 | + pos++; // skip '(' |
| 56 | + const expr = parseExpression(); |
| 57 | + if (tokens[pos] !== ')') { |
| 58 | + throw new Error(`Expected ')' at position ${pos}`); |
| 59 | + } |
| 60 | + pos++; // skip ')' |
| 61 | + return expr; |
| 62 | + } |
| 63 | + if (tokens[pos + 1] === '[') { // Start of an array Filter |
| 64 | + const path = tokens[pos++]; |
| 65 | + pos++; // skip '[' |
| 66 | + const expr = parseExpression(); |
| 67 | + if (tokens[pos] !== ']') { |
| 68 | + throw new Error(`Expected ']' at position ${pos}`); |
| 69 | + } |
| 70 | + pos++; // skip ']' |
| 71 | + return { expr, path, type: 'array' }; |
| 72 | + } |
| 73 | + return parseCondition(); // If its neither a nested nor array filter its a simple "propertyKey eq Value" |
| 74 | + } |
| 75 | + |
| 76 | + /** Parses a basic SCIM condition (e.g., userName eq "Joe") */ |
| 77 | + function parseCondition(): ScimNode { |
| 78 | + const attr = tokens[pos++]; // First token is the attribute |
| 79 | + const op: Comparator = tokens[pos++].toLowerCase() as Comparator; // Second token is the operator |
| 80 | + if (!['aco', 'co', 'eq', 'ew', 'ge', 'gt', 'le', 'lt', 'pr', 'sw'].includes(op)) { |
| 81 | + throw new Error(`Unsupported comparator: ${op}`); |
| 82 | + } |
| 83 | + |
| 84 | + let value: any = null; |
| 85 | + |
| 86 | + if (op !== 'pr') { // "Is Present" doesnt require a value |
| 87 | + let rawValue = tokens[pos++]; // Third token is the value |
| 88 | + if (!attr || !op || rawValue === undefined) { |
| 89 | + throw new Error(`Invalid condition syntax at token ${pos}`); |
| 90 | + } |
| 91 | + |
| 92 | + // Handle quoted strings |
| 93 | + if (rawValue?.startsWith('"')) { |
| 94 | + rawValue = rawValue.slice(1, -1); |
| 95 | + // For quoted strings, keep as string (don't parse to number/boolean) |
| 96 | + value = rawValue; |
| 97 | + } else { |
| 98 | + // For unquoted values, parse to appropriate type (number, boolean, or string) |
| 99 | + value = parseValue(rawValue); |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + return { attributePath: attr, comparator: op, type: 'condition', value }; |
| 104 | + } |
| 105 | + |
| 106 | + return parseExpression(); |
| 107 | +} |
| 108 | + |
| 109 | + |
| 110 | + /** Converts string values to appropriate types (number, boolean, or string) */ |
| 111 | +function parseValue(value: string): any { |
| 112 | + if (value === null || value === undefined) { |
| 113 | + return value; |
| 114 | + } |
| 115 | + |
| 116 | + // Check if it's a number (integer or float) |
| 117 | + if (/^-?\d+(\.\d+)?$/.test(value)) { |
| 118 | + const numValue = Number(value); |
| 119 | + return isNaN(numValue) ? value : numValue; |
| 120 | + } |
| 121 | + |
| 122 | + // Check if it's a boolean |
| 123 | + if (value.toLowerCase() === 'true') { |
| 124 | + return true; |
| 125 | + } |
| 126 | + if (value.toLowerCase() === 'false') { |
| 127 | + return false; |
| 128 | + } |
| 129 | + |
| 130 | + // Return as string for everything else |
| 131 | + return value; |
| 132 | +} |
| 133 | + |
| 134 | +/** |
| 135 | + * Tokenizes a SCIM filter string into meaningful parts. |
| 136 | + * e.g., 'userName eq "john"' → ['userName', 'eq', '"john"'] |
| 137 | + */ |
| 138 | +function tokenize(input: string): string[] { |
| 139 | + // Space out brackets, but not inside quoted strings |
| 140 | + let result = ''; |
| 141 | + let insideQuotes = false; |
| 142 | + |
| 143 | + for (let i = 0; i < input.length; i++) { |
| 144 | + const char = input[i]; |
| 145 | + |
| 146 | + if (char === '"' && (i === 0 || input[i - 1] !== '\\')) { |
| 147 | + insideQuotes = !insideQuotes; |
| 148 | + result += char; |
| 149 | + } else if (!insideQuotes && /[()[\]]/.test(char)) { |
| 150 | + result += ` ${char} `; |
| 151 | + } else { |
| 152 | + result += char; |
| 153 | + } |
| 154 | + } |
| 155 | + |
| 156 | + return result |
| 157 | + .replace(/\s+/g, ' ') // Normalise whitespaces |
| 158 | + .trim() |
| 159 | + .match(/\[|]|\(|\)|[a-zA-Z0-9_.]+|"(?:[^"\\]|\\.)*"/g) || []; // Match tokens: brackets, identifiers, quoted strings |
| 160 | +} |
| 161 | + |
| 162 | +/** Converts the parsed SCIM AST to an equivalent MongoDB query object */ |
| 163 | +function transformAstToMongo(node: ScimNode): any { |
| 164 | + if (!node) { |
| 165 | + return {}; |
| 166 | + } |
| 167 | + |
| 168 | + if ('operator' in node) { |
| 169 | + const operator = node.operator; |
| 170 | + const conditions = flattenLogicalOperator(node, operator); |
| 171 | + |
| 172 | + return { |
| 173 | + [`$${operator}`]: conditions.map(transformAstToMongo), |
| 174 | + }; |
| 175 | + } |
| 176 | + |
| 177 | + if (node.type === 'array') { |
| 178 | + return { |
| 179 | + [node.path]: { |
| 180 | + $elemMatch: transformAstToMongo(node.expr), |
| 181 | + }, |
| 182 | + }; |
| 183 | + } |
| 184 | + |
| 185 | + const { attributePath, comparator, value } = node; |
| 186 | + switch (comparator) { |
| 187 | + case 'aco': // ARRAY-CONTAINS (Not case sensitive) |
| 188 | + return { [attributePath]: value }; |
| 189 | + case 'co': // CONTAINS |
| 190 | + return { [attributePath]: { $options: 'i', $regex: escapeRegex(value) } }; |
| 191 | + case 'eq': // EQUALS |
| 192 | + return { [attributePath]: { $eq: value } }; |
| 193 | + case 'ew': // ENDSWITH |
| 194 | + return { [attributePath]: { $options: 'i', $regex: `${escapeRegex(value)}$` } }; |
| 195 | + case 'ge': // GREATER THAN OR EQUAL |
| 196 | + return { [attributePath]: { $gte: value } }; |
| 197 | + case 'gt': // GREATER THAN |
| 198 | + return { [attributePath]: { $gt: value } }; |
| 199 | + case 'le': // LESS THAN OR EQUAL |
| 200 | + return { [attributePath]: { $lte: value } }; |
| 201 | + case 'lt': // LESS THAN |
| 202 | + return { [attributePath]: { $lt: value } }; |
| 203 | + case 'pr': // PRESENT (exists) |
| 204 | + return { [attributePath]: { $exists: true } }; |
| 205 | + case 'sw': // STARTSWITH |
| 206 | + return { [attributePath]: { $options: 'i', $regex: `^${escapeRegex(value)}` } }; |
| 207 | + default: |
| 208 | + throw new Error(`Unsupported comparator: ${comparator}`); |
| 209 | + } |
| 210 | +} |
| 211 | + |
| 212 | +/* |
| 213 | +================ EXAMPLES ================ |
| 214 | +
|
| 215 | +Simple condition: |
| 216 | + SCIM: 'userName eq "Joe"' |
| 217 | + → Tokens: ['userName', 'eq', '"Joe"'] |
| 218 | + → AST: { attributePath: 'userName', comparator: 'eq', value: 'Joe' } |
| 219 | + → Mongo: { userName: { $eq: 'Joe' } } |
| 220 | +
|
| 221 | +Logical combination: |
| 222 | + SCIM: 'userName eq "Joe" and drinksCoffee eq true' |
| 223 | + → Tokens: ['userName', 'eq', '"Joe"', 'and', 'drinksCoffee', 'eq', 'true'] |
| 224 | + → AST: |
| 225 | + { |
| 226 | + operator: 'and', |
| 227 | + left: { attributePath: 'userName', comparator: 'eq', value: 'Joe' }, |
| 228 | + right: { attributePath: 'drinksCoffee', comparator: 'eq', value: 'true' } |
| 229 | + } |
| 230 | + → Mongo: |
| 231 | + { |
| 232 | + $and: [ |
| 233 | + { userName: { $eq: 'Joe' } }, |
| 234 | + { drinksCoffee: { $eq: 'true' } } |
| 235 | + ] |
| 236 | + } |
| 237 | +
|
| 238 | +Array filter: |
| 239 | + SCIM: 'emails[type eq "work"]' |
| 240 | + → Tokens: ['emails', '[', 'type', 'eq', '"work"', ']'] |
| 241 | + → AST: |
| 242 | + { |
| 243 | + type: 'array', |
| 244 | + path: 'emails', |
| 245 | + expr: { attributePath: 'type', comparator: 'eq', value: 'work' } |
| 246 | + } |
| 247 | + → Mongo: |
| 248 | + { |
| 249 | + emails: { |
| 250 | + $elemMatch: { type: { $eq: 'work' } } |
| 251 | + } |
| 252 | + } |
| 253 | +
|
| 254 | +*/ |
0 commit comments