Repository: scimmyjs/scimmy (js)

Issue:
Filters on 'complex' attribute arrays don't seem to work
I have built my integration using scimmy and it mostly seems to be working.  However, I pulled down the example PostMan tests mentioned here https://github.com/scimmyjs/scimmy-routers/issues/10#issuecomment-1947715384

I brought in parse/filter from `scim2-parse-filter` for applying filters, but the attribute filtering seems to be automatic/built in.

The issue is that there is a test that hits `/Users?attributes=emails[type eq "work"]`.  The core problem is that even though I have data that should match this attribute filter, it doesn't seem to work correctly.  For example, you can see if I limit myself to ids I created to test, I have rows that do have an email sub-object of type "work":
![image](https://github.com/user-attachments/assets/080ca3c7-f5b5-41ba-8caf-9e1af101fd5f)

However, if I add back in the filter on `type eq "work"`, it finds no emails:
![image](https://github.com/user-attachments/assets/a22db6e4-9584-4aa9-9469-e63252957141)

Which I think is wrong

I put some breakpoints in this library and I ended up tracing down into `#filter` in `scimmy/dist/lib/types.js` and it seemed like the issue was that it would see that it was an array filter on a complex attribute, so it'd recurse onto the user.emails, then recurse to user.emails[0], but the `inclusions` object is empty because this if on line 1755 doesn't pass:
```js
for (let key in {...filterable}) if (Array.isArray(filterable[key]) && filterable[key][0] === "pr") { 
```
so the `emails.type` gets ignored, so its an empty object with no inclusions, so my emails[0] doesn't pass the filter `type eq "work"`, which when I bubble up to the part where its filtering the array, we see `v` is empty now, so it gets thrown out:

![image](https://github.com/user-attachments/assets/6a0f9618-ba5f-47a6-8253-9aad062ae867)

Pull request description:
Add support for filtering of complex multi-value attributes in `coerce` method of `SCIMMY.Types.SchemaDefinition`
Currently, the `SCIMMY.Types.Filter` class does not parse comparators for complex filter expressions unless they are followed by a trailing attribute name (i.e. `emails[type eq "work"].value pr` as opposed to `emails[type eq "work"] pr`). When the trailing attribute name is missing, the comparator is ignored and the resulting filter is incomplete. This has flow on effects for the `coerce` method of the `SCIMMY.Types.SchemaDefinition` class, which is no longer instructed to include the `emails` attribute on the coerced value. Additional issues identified include the `coerce` method not evaluating all entries in a supplied filter (or even verifying the filter is an instance of the `Filter` class), and not omitting extension namespaces not explicitly included in a list of expected attribute inclusions.

Whilst technically defined in [RFC7644§3.10](https://datatracker.ietf.org/doc/html/rfc7644#section-3.10) without reference to complex value filters, there is benefit in being capable of parsing and handling these expressions in the `attributes` parameter. Specifically, one would reasonably expect that an `attributes` parameter value of `emails[type eq "work"]` is a request to include only those `emails` that have a `type` value of `work` in the response.

This change adds support in the `Filter` class for parsing comparators of complex filter expressions not followed by trailing attribute names, updating relevant internal methods to account for this scenario. Test fixtures for the `Filter` class have been updated to verify correct parsing and matching of complex filter expressions not followed by trailing attribute names. Leveraging this change, the `coerce` method of the `SchemaDefinition` class has been updated to add support for filtering complex multi-value attributes, only including items that match the given expression in the attribute value (fixes #55). The `coerce` method has also been updated to verify the supplied filter (if any) is an instance of the `Filter` class, evaluate all expressions in a filter, and omit extension namespaces not explicitly included in a list of expected attribute inclusions.

Patch:
diff --git a/src/lib/types/definition.js b/src/lib/types/definition.js
index 38bdd21..53b381f 100644
--- a/src/lib/types/definition.js
+++ b/src/lib/types/definition.js
@@ -155,7 +155,7 @@ export class SchemaDefinition {
             
             // Go through the schema extension definition and directly register any nested schema definitions
             const surplusSchemas = extension.attributes.filter(e => e instanceof SchemaDefinition);
-            for (let definition of surplusSchemas) this.extend(definition);
+            for (let definition of surplusSchemas) this.extend(Object.getPrototypeOf(definition));
         }
         // If every extension is an attribute instance, add them to the schema definition
         else if (extensions.every(e => e instanceof Attribute)) {
@@ -217,24 +217,26 @@ export class SchemaDefinition {
      * @param {Object} data - value to coerce and confirm conformity of properties to schema attributes' characteristics
      * @param {String} [direction="both"] - whether to check for inbound, outbound, or bidirectional attributes
      * @param {String} [basepath] - the URI representing the resource type's location
-     * @param {SCIMMY.Types.Filter} [filters] - the attribute filters to apply to the coerced value
+     * @param {SCIMMY.Types.Filter} [filter] - the attribute filters to apply to the coerced value
      * @returns {Object} the coerced value, conforming to all schema attributes' characteristics
      */
-    coerce(data, direction = "both", basepath, filters) {
+    coerce(data, direction = "both", basepath, filter) {
         // Make sure there is data to coerce...
         if (data === undefined || Array.isArray(data) || Object(data) !== data)
             throw new TypeError("Expected 'data' parameter to be an object in SchemaDefinition instance");
+        // If specified, make sure filter is an instance of Filter class
+        if (filter !== undefined && !(filter instanceof Filter))
+            throw new TypeError("Expected 'filter' parameter to be a Filter instance in SchemaDefinition instance");
         
-        // Get the filter and coercion target ready
-        const filter = (filters ?? []).slice(0).shift();
+        // Get the coercion target ready, compile a list of schema IDs to include in the resource
         const target = {};
-        // Compile a list of schema IDs to include in the resource
         const schemas = [...new Set([
             this.id,
             ...(this.attributes.filter(a => a instanceof SchemaDefinition).map(s => s.id)
                 .filter(id => !!data[id] || Object.keys(data).some(d => d.startsWith(`${id}:`)))),
             ...(Array.isArray(data.schemas) ? data.schemas : [])
         ])];
+        
         // Add schema IDs, and schema's name as resource type to meta attribute
         const source = {
             // Cast all key names to lower case to eliminate case sensitivity....
@@ -277,7 +279,7 @@ export class SchemaDefinition {
                         return res;
                     }, {});
                 // Mix the namespaced attribute values in with the extension value
-                const mixedSource = [source[name.toLowerCase()] ?? {}, namespacedValues ?? {}].reduce(function merge(t, s) {
+                const mixedSource = [source[name.toLowerCase()] ?? {}, namespacedValues].reduce(function merge(t, s) {
                     // Cast all key names to lower case to eliminate case sensitivity....
                     t = (Object.keys(t).reduce((res, key) => Object.assign(res, {[key.toLowerCase()]: t[key]}), {}));
                     
@@ -305,12 +307,16 @@ export class SchemaDefinition {
                 if (!!required && !Object.keys(mixedSource).length) {
                     throw new TypeError(`Missing values for required schema extension '${name}'`);
                 } else if (required || Object.keys(mixedSource).length) {
+                    // See if there are any namespaced attribute filters for this extension
+                    const namespacedFilters = (filter ?? [])
+                        // Start by only dealing with expressions that contain this extension...
+                        .map((filter) => Object.entries(filter).filter(([k]) => k.startsWith(`${name}:`))).filter((filter) => filter.length)
+                        // ...then remove the extension prefix
+                        .map((filter) => filter.reduce((res, [key, val]) => Object.assign(res, {[key.replace(`${name}:`, "")]: val}), {}))
+                    
                     try {
                         // Coerce the mixed value, using only namespaced attributes for this extension
-                        target[name] = attribute.coerce(mixedSource, direction, basepath, [Object.keys(filter ?? {})
-                            .filter(k => k.startsWith(`${name}:`))
-                            .reduce((res, key) => Object.assign(res, {[key.replace(`${name}:`, "")]: filter[key]}), {})
-                        ]);
+                        target[name] = attribute.coerce(mixedSource, direction, basepath, ...(namespacedFilters.length ? [new Filter(namespacedFilters)] : []));
                     } catch (ex) {
                         // Rethrow exception with added context
                         ex.message += ` in schema extension '${name}'`;
@@ -320,7 +326,8 @@ export class SchemaDefinition {
             }
         }
         
-        return SchemaDefinition.#filter(this, filter && {...filter}, target);
+        // Go through and apply each filter expression individually to get coerced value
+        return (filter ?? [filter]).reduce((target, filter) => SchemaDefinition.#filter(this, filter, target), target);
     }
     
     /**
@@ -338,7 +345,7 @@ export class SchemaDefinition {
             return data;
         // If the data is a set, only get values that match the filter
         else if (Array.isArray(data))
-            return data.map(data => SchemaDefinition.#filter(definition, {...filter}, data, prefix)).filter(v => Object.keys(v).length);
+            return data.map(data => SchemaDefinition.#filter(definition, filter, data, prefix)).filter(v => Object.keys(v).length);
         // Otherwise, filter the data!
         else {
             // Prepare resultant value storage
@@ -372,18 +379,24 @@ export class SchemaDefinition {
                 // ...go through all subAttributes, or extension attributes...
                 for (let attribute of (prefix ? definition.attribute(prefix).subAttributes : definition.attributes)) {
                     // ...and assume they should be included, if they weren't explicitly excluded
-                    if (attribute instanceof Attribute && !exclusions.includes(attribute.name)) inclusions.push(attribute.name);
+                    const name = (attribute instanceof SchemaDefinition ? attribute.id : attribute.name);
+                    if (!exclusions.includes(name)) inclusions.push(name);
                 }
             }
+            // If there were explicit inclusions, go through all attributes...
+            else if (inclusions.length) for (let attribute of definition.attributes) {
+                // ...and exclude any extension schemas for which no filter is defined
+                if (attribute instanceof SchemaDefinition && !Object.keys(filter).some(key => key.startsWith(attribute.id))) exclusions.push(attribute.id);
+            }
             
             // Go through every value in the data and filter it
             for (let key in data) {
                 // Get the matching attribute or extension definition for the key
-                const attribute = definition.attribute(prefix ? `${prefix}.${key}` : key) ?? {};
+                const attribute = definition.attribute(prefix ? `${prefix}.${key}` : key);
                 
                 if (attribute instanceof SchemaDefinition) {
                     // If there is data in a namespaced key and no namespace filter, or there's an explicit inclusion filter...
-                    if ((Object.keys(data[key]).length && !Array.isArray(filter[key])) || (key in filter && inclusions.includes(key)))
+                    if ((!exclusions.includes(key) && Object.keys(data[key]).length && !Array.isArray(filter[key])) || (key in filter && inclusions.includes(key)))
                         // ...include the extension data
                         target[key] = data[key];
                 } else {
@@ -394,9 +407,10 @@ export class SchemaDefinition {
                     if (returned === "always") target[key] = data[key];
                     // Otherwise, if the attribute was requested and ~can~ be returned, process it
                     else if (![false, "never"].includes(returned)) {
-                        // If there's a filter for a complex attribute, evaluate it
-                        if (key in filter && !Array.isArray(filter[key]) && type === "complex") {
-                            const value = SchemaDefinition.#filter(definition, filter[key], data[key], key);
+                        // If there's a filter for a complex attribute, evaluate it...
+                        if (key in filter && type === "complex") {
+                            // ...either using Filter instance match method, or by recursing into this filter method to get specified attributes
+                            const value = Array.isArray(filter[key]) ? new Filter(filter[key].filter((expr) => Object.getPrototypeOf(expr).constructor === Object)).match(data[key]) : SchemaDefinition.#filter(definition, filter[key], data[key], key);
                             
                             // Only set the value if it isn't empty
                             if ((!multiValued && value !== undefined) || (Array.isArray(value) && value.length))
diff --git a/src/lib/types/filter.js b/src/lib/types/filter.js
index 0ac2911..ff486e2 100644
--- a/src/lib/types/filter.js
+++ b/src/lib/types/filter.js
@@ -261,7 +261,7 @@ export class Filter extends Array {
         const isString = typeof expression === "string";
         
         // Make sure expression is a string, an object, or an array of objects
-        if (!isString && !(Array.isArray(expression) ? expression : [expression]).every(e => Object.getPrototypeOf(e).constructor === Object))
+        if (!isString && !(Array.isArray(expression) ? expression : [expression]).every(e => !!e && Object.getPrototypeOf(e).constructor === Object))
             throw new TypeError("Expected 'expression' parameter to be a string, object, or array of objects in Filter constructor");
         // Make sure the expression string isn't empty
         if (isString && !expression.trim().length)
@@ -289,13 +289,15 @@ export class Filter extends Array {
     match(values) {
         // Match against any of the filters in the set
         return values.filter(value => 
-            this.some(f => (f !== Object(f) ? false : Object.entries(f).every(([attr, expressions]) => {
-                let [,actual] = Object.entries(value).find(([key]) => key.toLowerCase() === attr.toLowerCase()) ?? [];
+            this.some(f => Object.entries(f).every(([attr, expressions]) => {
+                const [,actual] = Object.entries(value).find(([key]) => key.toLowerCase() === attr.toLowerCase()) ?? [];
                 const isActualDate = (actual instanceof Date || (new Date(actual).toString() !== "Invalid Date" && String(actual).match(isoDate)));
                 
                 if (Array.isArray(actual)) {
-                    // Handle multivalued attributes by diving into them
-                    return !!(new Filter(expressions).match(actual).length);
+                    // Handle multivalued attributes by diving into them...
+                    return !!(Array.isArray(expressions) ? expressions : [expressions])
+                        // ...dealing with nested joins involving complex filter expressions
+                        .some(e => new Filter(Array.isArray(e) ? {[attr]: e} : e).match(actual).length);
                 } else if (!Array.isArray(expressions)) {
                     // Handle complex attributes by diving into them
                     return !!(new Filter([expressions]).match([actual]).length);
@@ -370,7 +372,7 @@ export class Filter extends Array {
                     
                     return result;
                 }
-            })))
+            }))
         );
     }
     
@@ -392,7 +394,7 @@ export class Filter extends Array {
             const index = originIndex ?? expressions.indexOf(e)+1;
             const props = Object.entries(e);
             
-            // Make sure the expression isn't empty... 
+            // Make sure the expression isn't empty...
             if (!props.length) {
                 if (!prefix) throw new TypeError(`Missing expression properties for Filter expression object #${index}`);
                 else throw new TypeError(`Missing expressions for property '${prefix.slice(0, -1)}' of Filter expression object #${index}`);
@@ -409,30 +411,34 @@ export class Filter extends Array {
                     const nested = expr.some(e => Array.isArray(e));
                     
                     // Make sure expression is either singular or nested, not both
-                    if (nested && expr.length && !expr.every(e => Array.isArray(e)))
+                    if (nested && expr.length && !expr.every(e => Array.isArray(e) || Object.getPrototypeOf(e).constructor === Object))
                         throw new TypeError(`Unexpected nested array in property '${name}' of Filter expression object #${index}`);
                     
                     // Go through and make sure each expression is valid
                     for (let e of (nested ? expr : [expr])) {
-                        // Extract comparator and expected value
-                        const [comparator, expected] = e.slice(e[0]?.toLowerCase?.() === "not" ? 1 : 0);
-                        
-                        // Make sure there was a comparator
-                        if (!comparator)
-                            throw new TypeError(`Missing comparator in property '${name}' of Filter expression object #${index}`);
-                        // Make sure presence comparators don't include expected values
-                        if (["pr", "np"].includes(comparator.toLowerCase()) && expected !== undefined)
-                            throw new TypeError(`Unexpected comparison value for '${comparator}' comparator in property '${name}' of Filter expression object #${index}`);
-                        // Make sure expected value was defined for any other comparator
-                        if (expected === undefined && !["pr", "np"].includes(comparator.toLowerCase()))
-                            throw new TypeError(`Missing expected comparison value for '${comparator}' comparator in property '${name}' of Filter expression object #${index}`);
+                        // If the expression is a nested object, make sure it's also valid
+                        if (!Array.isArray(e)) Filter.#validate(e, index, name);
+                        else {
+                            // Extract comparator and expected value
+                            const [comparator, expected] = e.slice(e[0]?.toLowerCase?.() === "not" ? 1 : 0);
+                            
+                            // Make sure there was a comparator
+                            if (!comparator || typeof comparator !== "string")
+                                throw new TypeError(`Missing comparator in property '${name}' of Filter expression object #${index}`);
+                            // Make sure presence comparators don't include expected values
+                            if (["pr", "np"].includes(comparator.toLowerCase()) && expected !== undefined)
+                                throw new TypeError(`Unexpected comparison value for '${comparator}' comparator in property '${name}' of Filter expression object #${index}`);
+                            // Make sure expected value was defined for any other comparator
+                            if (expected === undefined && !["pr", "np"].includes(comparator.toLowerCase()))
+                                throw new TypeError(`Missing expected comparison value for '${comparator}' comparator in property '${name}' of Filter expression object #${index}`);
+                        }
                     }
                 }
                 // If expression is an object, traverse it
                 else if (Object.getPrototypeOf(expr).constructor === Object)
                     Filter.#validate(expr, index, `${name}.`);
                 // Otherwise, the expression is not valid
-                else throw new TypeError(`Expected plain object ${name ? `or expression array in property '${name}' of` : "for"} Filter expression object #${index}`)
+                else throw new TypeError(`Expected plain object or expression array in property '${name}' of Filter expression object #${index}`);
             }
         }
         
@@ -456,17 +462,21 @@ export class Filter extends Array {
                         const expressions = [];
                         
                         // Handle logical "and" operations applied to a single attribute
-                        for (let e of expr.every(e => Array.isArray(e)) ? expr : [expr]) {
-                            // Copy expression so original isn't modified
-                            const parts = [...e];
-                            // Then check for negations and extract the actual values
-                            const negate = (parts[0].toLowerCase() === "not" ? parts.shift() : undefined);
-                            const [comparator, expected] = parts;
-                            const maybeValue = expected instanceof Date ? expected.toISOString() : expected;
-                            const value = (typeof maybeValue === "string" ? `"${maybeValue}"` : (maybeValue !== undefined ? `${maybeValue}` : maybeValue))
-                            
-                            // Add the stringified expression to the results
-                            expressions.push([negate, `${prefix}${attr}`, comparator, value].filter(v => !!v).join(" "));
+                        for (let e of expr.every(e => Array.isArray(e) || Object.getPrototypeOf(e).constructor === Object) ? expr : [expr]) {
+                            // If expression isn't simple, update attribute name with complex filters
+                            if (!Array.isArray(e)) attr = `${attr}[${Object.entries(e).map(getMapper())}]`;
+                            else {
+                                // Copy expression so original isn't modified
+                                const parts = [...e];
+                                // Then check for negations and extract the actual values
+                                const negate = (parts[0].toLowerCase() === "not" ? parts.shift() : undefined);
+                                const [comparator, expected] = parts;
+                                const maybeValue = expected instanceof Date ? expected.toISOString() : expected;
+                                const value = (typeof maybeValue === "string" ? `"${maybeValue}"` : (maybeValue !== undefined ? `${maybeValue}` : maybeValue))
+                                
+                                // Add the stringified expression to the results
+                                expressions.push([negate, `${prefix}${attr}`, comparator, value].filter(v => !!v).join(" "));
+                            }
                         }
                         
                         return expressions;
@@ -513,7 +523,7 @@ export class Filter extends Array {
                 if (grouping !== undefined) tokens.push({type: "Group", value: grouping.substring(1, grouping.length - 1)});
                 
                 // Treat complex attribute filters as words
-                if (complex !== undefined) word = tokens.pop().value + complex;
+                if (complex !== undefined) word = tokens.pop()?.value + complex;
                 
                 // Handle attribute names (words), and unescaped string values
                 if (word !== undefined) {
@@ -605,7 +615,7 @@ export class Filter extends Array {
         else if (expressions.every(e => Object.getPrototypeOf(e).constructor === Object)) {
             return expressions.map(Filter.#objectify);
         }
-        // Go through every expression in the list, or handle a singular expression if that's what was given  
+        // Go through every expression in the list, or handle a singular expression if that's what was given
         else {
             const result = {};
             
@@ -631,7 +641,7 @@ export class Filter extends Array {
                         const expression = [negative, comparator.toLowerCase(), value].filter(v => v !== undefined);
                         
                         // Either store the single expression, or convert to array if attribute already has an expression defined
-                        target[name] = (!Array.isArray(target[name]) ? expression : [...(target[name].every(Array.isArray) ? target[name] : [target[name]]), expression]);
+                        target[name] = (target[name] === undefined ? expression : [...(Array.isArray(target[name]) && target[name].every(Array.isArray) ? target[name] : [target[name]]), expression]);
                     }
                 }
             }
@@ -652,12 +662,12 @@ export class Filter extends Array {
         // Initial pass to check for complexities
         const simple = !tokens.some(t => ["Operator", "Group"].includes(t.type));
         // Closer inspection in case word tokens contain nested attribute filters
-        const reallySimple = simple && (tokens[0]?.value ?? tokens[0] ?? "").split(pathSeparator)
+        const reallySimple = simple && tokens[0]?.value.split(pathSeparator)
             .every(t => t === multiValuedFilter.exec(t).slice(1).shift());
         
         // If there's no operators or groups, and no nested attribute filters, assume the expression is complete
         if (reallySimple) {
-            results.push(Array.isArray(query) ? tokens.map(t => t.value ?? t) : Filter.#objectify(tokens.splice(0).map(t => t?.value ?? t)));
+            results.push(Array.isArray(query) ? tokens.map(t => t.value) : Filter.#objectify(tokens.splice(0).map(t => t.value)));
         }
         // Otherwise, logic and groups need to be evaluated
         else {
@@ -691,7 +701,7 @@ export class Filter extends Array {
                         
                         for (let part of parts) {
                             // Check for filters in the path part
-                            const [, key = part, filter] = multiValuedFilter.exec(part) ?? [];
+                            const [, key = part, filter] = multiValuedFilter.exec(part);
                             
                             // Store the spent path part
                             spent.push(key);
@@ -702,15 +712,20 @@ export class Filter extends Array {
                                     // Get any branches in the nested filter, parse them for joins, and properly wrap them
                                     .#operations(Filter.#tokenise(filter.substring(1, filter.length - 1)), "or")
                                     .map(b => Filter.#parse(b))
-                                    .map(b => b.every(b => b.every(b => Array.isArray(b))) ? b.flat(1) : b)
                                     // Prefix any attribute paths with spent parts
                                     .map((branch) => branch.map(join => {
-                                        const negative = (join.length === 4 || (join.length === 3 && comparators.includes(join[join.length-1].toLowerCase())) ? join.shift() : undefined);
+                                        const negative = (join.length === 4 || (join.length === 3 && comparators.includes(join[join.length-1]?.toLowerCase?.())) ? join.shift() : undefined);
                                         const [path, comparator, value] = join;
                                         
-                                        return [negative?.toLowerCase?.(), `${spent.join(".")}.${path}`, comparator, value];
+                                        return [negative?.toLowerCase(), `${spent.join(".")}.${path}`, comparator, value];
                                     }));
                                 
+                                // If there was a filter, some comparator, and no chained attribute name...
+                                if (parts.indexOf(part) === parts.length - 1 && comparator !== undefined) {
+                                    // ...add the expression as a branch I guess?
+                                    for (let branch of branches) branch.push([negative?.value, spent.join("."), comparator.value, value?.value]);
+                                }
+                                
                                 if (!results.length) {
                                     // Extract results from the filter
                                     results.push(...branches);
@@ -727,7 +742,7 @@ export class Filter extends Array {
                             }
                             // No filter, but if we're at the end of the chain, join the last expression with the results
                             else if (parts.indexOf(part) === parts.length - 1) {
-                                for (let result of results) result.push([negative?.value, spent.join("."), comparator?.value, value?.value]);
+                                for (let result of results) result.push([negative?.value, spent.join("."), comparator.value, value?.value]);
                             }
                         }
                         
@@ -777,7 +792,7 @@ export class Filter extends Array {
             
             // Push all expressions to results, objectifying if necessary
             for (let expression of expressions) {
-                results.push(...(Array.isArray(query) ? (expression.every(t => Array.isArray(t)) ? expression : [expression]) : [Filter.#objectify(expression)]));
+                results.push(...(Array.isArray(query) && expression.every(Array.isArray) ? expression : [Filter.#objectify(expression)]));
             }
         }
         


Test patch:
diff --git a/test/lib/types/definition.js b/test/lib/types/definition.js
index 5a11676..f061963 100644
--- a/test/lib/types/definition.js
+++ b/test/lib/types/definition.js
@@ -304,6 +304,15 @@ describe("SCIMMY.Types.SchemaDefinition", () => {
             assert.strictEqual(Object.getPrototypeOf(definition.extend(extension).attribute(extension.id)), extension,
                 "Instance method 'extend' did not ignore already declared SchemaDefinition extension");
         });
+        
+        it("should expect nested schema definition extensions to be declared as extensions themselves", () => {
+            const nested = new SchemaDefinition("NestedDefinition", extensionId);
+            const extension = new SchemaDefinition(`${params.name}Extension`, `${params.id}Extension`).extend(nested);
+            const definition = new SchemaDefinition(...Object.values(params)).extend(extension);
+            
+            assert.strictEqual(Object.getPrototypeOf(definition.attribute(nested.id)), nested,
+                "Instance method 'extend' did not expect nested schema definition extensions to be declared as extensions themselves");
+        });
     });
     
     describe("#truncate()", () => {
@@ -441,6 +450,14 @@ describe("SCIMMY.Types.SchemaDefinition", () => {
                 "Instance method 'coerce' did not fail with 'data' argument array value");
         });
         
+        it("should expect 'filter' argument to be a Filter instance, if specified", () => {
+            const definition = new SchemaDefinition(...Object.values(params));
+            
+            assert.throws(() => definition.coerce({}, undefined, undefined, "test"),
+                {name: "TypeError", message: "Expected 'filter' parameter to be a Filter instance in SchemaDefinition instance"},
+                "Instance method 'coerce' did not expect 'filter' argument to be a Filter instance");
+        });
+        
         it("should expect common attributes to be defined on coerced result", () => {
             const definition = new SchemaDefinition(...Object.values(params));
             const result = definition.coerce({});
@@ -451,6 +468,15 @@ describe("SCIMMY.Types.SchemaDefinition", () => {
                 "Instance method 'coerce' did not set common attribute 'meta.resourceType' on coerced result");
         });
         
+        it("should always include always returned attributes", () => {
+            const source = {name: "Test", value: "False"};
+            const attributes = [new Attribute("string", "name", {returned: "always"}), new Attribute("string", "value")];
+            const definition = new SchemaDefinition(...Object.values(params), "Test Schema", attributes).truncate(["schemas", "meta"]);
+            
+            assert.deepStrictEqual({...definition.coerce(source, undefined, undefined, new Filter("name np"))}, source,
+                `Instance method 'coerce' did not always include always returned attributes`);
+        });
+        
         it("should expect coerce to be called on directly included attributes", () => {
             const stub = sinon.stub();
             const get = (t, p) => (p === "coerce" ? (...args) => (stub(...args) ?? t.coerce(...args)) : t[p]);
@@ -516,11 +542,18 @@ describe("SCIMMY.Types.SchemaDefinition", () => {
         });
         
         it("should expect deeply nested namespaced and extension attributes to be merged and coerced", () => {
-            const attributes = [new Attribute("complex", "test", {}, [new Attribute("string", "name"), new Attribute("string", "value")])]
+            const attributes = [new Attribute("string", "words", {multiValued: true}), new Attribute("complex", "test", {}, [new Attribute("string", "name"), new Attribute("string", "value")])];
             const extension = new SchemaDefinition("Extension", extensionId, "An Extension", attributes);
             const definition = new SchemaDefinition(...Object.values(params)).extend(extension, true);
-            const {[extension.id]: actual} = definition.coerce({[`${extension.id}:test.value`]: "Test", [extension.id]: {test: {name: "Test"}}});
-            const expected = {test: {name: "Test", value: "Test"}};
+            const expected = {words: ["first", "second"], test: {name: "Test", value: "Test"}};
+            const {[extension.id]: actual} = definition.coerce({
+                [`${extension.id}:words`]: ["second"],
+                [`${extension.id}:test.value`]: "Test",
+                [extension.id]: {
+                    words: ["first"],
+                    test: {name: "Test", value: "Wrong"}
+                }
+            });
             
             assert.deepStrictEqual({...actual}, expected,
                 "Instance method 'coerce' did not expect deeply nested namespaced and extension attributes to be merged and coerced");
@@ -562,7 +595,8 @@ describe("SCIMMY.Types.SchemaDefinition", () => {
             ["complex attributes", "filtered positively", "unexpectedly included", {test: {value: "False"}}, "test.value pr"],
             ["complex attributes", "filtered negatively", "unexpectedly excluded", {test: {name: "Test"}}, "test.value np"],
             ["complex multi-value attributes", "filtered positively", "unexpectedly included", {test: [{name: "Test"}]}, "test.name pr", true],
-            ["complex multi-value attributes", "filtered negatively", "unexpectedly excluded", {test: [{value: "Test"}, {value: "False"}]}, "test.name np", true]
+            ["complex multi-value attributes", "filtered negatively", "unexpectedly excluded", {test: [{value: "Test"}, {value: "False"}]}, "test.name np", true],
+            ["complex multi-value attributes", "matched against filter expressions", "unexpectedly excluded", {test: [{name: "Test", value: "Test"}]}, "test[name eq \"Test\"] pr", true]
         ]) it(`should expect ${target} to be ${outcome}`, () => {
             const source = {test: multiValued ? [{name: "Test", value: "Test"}, {value: "False"}] : {name: "Test", value: "False"}};
             const attributes = [new Attribute("complex", "test", {multiValued}, [new Attribute("string", "name"), new Attribute("string", "value")])];
@@ -573,9 +607,10 @@ describe("SCIMMY.Types.SchemaDefinition", () => {
         });
         
         for (let [target, outcome, unexpected, expected, filter] of [
-            ["namespaced attributes", "present in coerced result", "unexpectedly excluded", {costCenter: "Test", [extensionId]: {employeeNumber: "1234", costCenter: "Test"}}, "employeeNumber np"],
-            ["namespaced attributes", "filtered positively", "unexpectedly included", {[extensionId]: {employeeNumber: "1234"}}, `${extensionId}:employeeNumber pr`],
-            ["namespaced attributes", "filtered negatively", "unexpectedly excluded", {employeeNumber: "Test", costCenter: "Test", [extensionId]: {costCenter: "Test"}}, `${extensionId}:employeeNumber np`],
+            ["namespaced attributes", "included when not specified in negative filter", "unexpectedly excluded", {costCenter: "Test", [extensionId]: {employeeNumber: "1234", costCenter: "Test"}}, "employeeNumber np"],
+            ["namespaced attributes", "excluded when not specified in positive filter", "unexpectedly included", {costCenter: "Test"}, "costCenter pr"],
+            ["namespaced attributes", "filtered positively", "unexpectedly excluded", {[extensionId]: {employeeNumber: "1234"}}, `${extensionId}:employeeNumber pr`],
+            ["namespaced attributes", "filtered negatively", "unexpectedly included", {employeeNumber: "Test", costCenter: "Test", [extensionId]: {costCenter: "Test"}}, `${extensionId}:employeeNumber np`],
             ["extension namespaces", "filtered positively", "unexpectedly included", {[extensionId]: {employeeNumber: "1234", costCenter: "Test"}}, `${extensionId} pr`],
             ["extension namespaces", "filtered negatively", "unexpectedly excluded", {employeeNumber: "Test", costCenter: "Test"}, `${extensionId} np`],
             ["direct and namespaced attributes", "filtered positively", "unexpectedly included", {costCenter: "Test", [extensionId]: {employeeNumber: "1234"}}, `costCenter pr and ${extensionId}:employeeNumber pr`],
diff --git a/test/lib/types/filter.js b/test/lib/types/filter.js
index 9c11f27..5d54cd9 100644
--- a/test/lib/types/filter.js
+++ b/test/lib/types/filter.js
@@ -15,9 +15,10 @@ describe("SCIMMY.Types.Filter", () => {
     });
     
     describe("@constructor", () => {
-        it("should not require arguments", () => {
-            assert.doesNotThrow(() => new Filter("id pr"),
-                "Filter type class did not instantiate without arguments");
+        it("should require 'expression' argument to be defined", () => {
+            assert.throws(() => new Filter(),
+                {name: "TypeError", message: "Expected 'expression' parameter to be a string, object, or array of objects in Filter constructor"},
+                "Filter type class instantiated without arguments");
         });
         
         context("when 'expression' argument is defined", () => {
@@ -143,7 +144,28 @@ describe("SCIMMY.Types.Filter", () => {
                     "Filter type class did not expect expression comparators to be defined");
             });
             
-            it("should throw when nested arrays are mixed with singular expressions", () => {
+            it("should expect expression comparators to be valid", () => {
+                assert.throws(() => new Filter({id: ["pr", "test"]}),
+                    {name: "TypeError", message: "Unexpected comparison value for 'pr' comparator in property 'id' of Filter expression object #1"},
+                    "Filter type class did not expect expression comparators to be valid");
+                assert.throws(() => new Filter({id: ["eq"]}),
+                    {name: "TypeError", message: "Missing expected comparison value for 'eq' comparator in property 'id' of Filter expression object #1"},
+                    "Filter type class did not expect expression comparators to be valid");
+            });
+            
+            it("should only accept Date instances as comparison values", () => {
+                try {
+                    new Filter({date: ["eq", new Date()]});
+                } catch (ex) {
+                    assert.fail(`Filter type class did not accept Date instances as comparison values\r\n[cause]: ${ex}`);
+                } finally {
+                    assert.throws(() => new Filter({date: [new Date()]}),
+                        {name: "TypeError", message: "Missing comparator in property 'date' of Filter expression object #1"},
+                        "Filter type class did not reject Date instance in place of comparator");
+                }
+            });
+            
+            it("should expect nested arrays to not be mixed with singular expressions", () => {
                 assert.throws(() => new Filter({id: ["pr", ["sw", "A"]]}),
                     {name: "TypeError", message: "Unexpected nested array in property 'id' of Filter expression object #1"},
                     "Filter type class did not throw when nested arrays were mixed with singular expressions");
@@ -245,13 +267,20 @@ describe("SCIMMY.Types.Filter", () => {
                     "Instance member 'expression' was not a string");
             });
             
+            it("should stringify Date instances", () => {
+                const date = new Date();
+                
+                assert.strictEqual(new Filter({date: ["eq", date]}).expression, `date eq "${date.toISOString()}"`,
+                    "Instance member 'expression' did not equal supplied date");
+            });
+            
             it("should stringify simple expression objects without logical or grouping operators", async function () {
                 const {expression: {simple: suite}} = await fixtures;
                 
                 if (!suite.length) this.skip();
                 else for (let fixture of suite) {
                     assert.deepStrictEqual((new Filter(fixture.source)).expression, fixture.target,
-                        `Filter type class failed to stringify simple expression object '${JSON.stringify(fixture.source)}'`);
+                        `Filter type class failed to stringify simple expression object\r\n[expression]: ${JSON.stringify(fixture.source)}`);
                 }
             });
             
@@ -261,7 +290,7 @@ describe("SCIMMY.Types.Filter", () => {
                 if (!suite.length) this.skip();
                 else for (let fixture of suite) {
                     assert.deepStrictEqual((new Filter(fixture.source)).expression, fixture.target,
-                        `Filter type class failed to stringify expression object '${JSON.stringify(fixture.source)}' with logical operators`);
+                        `Filter type class failed to stringify expression object with logical operators\r\n[expression]: ${JSON.stringify(fixture.source)}`);
                 }
             });
             
@@ -271,7 +300,7 @@ describe("SCIMMY.Types.Filter", () => {
                 if (!suite.length) this.skip();
                 else for (let fixture of suite) {
                     assert.deepStrictEqual((new Filter(fixture.source)).expression, fixture.target,
-                        `Filter type class failed to stringify complex expression object '${JSON.stringify(fixture.source)}'`);
+                        `Filter type class failed to stringify complex expression object\r\n[expression]: ${JSON.stringify(fixture.source)}`);
                 }
             });
         });
@@ -301,8 +330,14 @@ describe("SCIMMY.Types.Filter", () => {
                 
                 if (!suite.length) this.skip();
                 else for (let fixture of suite) {
-                    assert.deepStrictEqual(new Filter(fixture.expression).match(source).map((v) => v.id), fixture.expected,
-                        `Unexpected matches in '${key}' fixture #${suite.indexOf(fixture) + 1} with expression '${JSON.stringify(fixture.expression)}'`);
+                    const result = await Promise.resolve()
+                        .then(() => new Filter(fixture.expression))
+                        .catch((ex) => assert.fail(`Failed to instantiate Filter type class\r\n[expression]: ${JSON.stringify(fixture.expression)}\r\n[cause]: ${ex}`))
+                        .then((filter) => filter.match(source).map((v) => v.id))
+                        .catch((ex) => assert.fail(`Instance method 'match' failed unexpectedly\r\n[expression]: ${JSON.stringify(fixture.expression)}\r\n[cause]: ${ex.stack}`));
+                    
+                    assert.deepStrictEqual(result, fixture.expected,
+                        `Unexpected matches in '${key}' fixture #${suite.indexOf(fixture) + 1}\r\n[expression]: ${JSON.stringify(fixture.source)}`);
                 }
             });
         }
diff --git a/test/lib/types/filter.json b/test/lib/types/filter.json
index 09c3353..530f5ad 100644
--- a/test/lib/types/filter.json
+++ b/test/lib/types/filter.json
@@ -112,6 +112,56 @@
       }
     ],
     "complex": [
+      {
+        "source": "emails[type eq \"work\"] pr",
+        "target": [
+          {"emails": [{"type": ["eq", "work"]}, ["pr"]]}
+        ]
+      },
+      {
+        "source": "not emails[type eq \"work\"].value pr",
+        "target": [
+          {"emails": {"type": ["eq", "work"], "value": ["not", "pr"]}}
+        ]
+      },
+      {
+        "source": "emails[not type eq \"work\"] pr",
+        "target": [
+          {"emails": [{"type": ["not", "eq", "work"]}, ["pr"]]}
+        ]
+      },
+      {
+        "source": "emails[type eq \"work\"] co \"A\"",
+        "target": [
+          {"emails": [{"type": ["eq", "work"]}, ["co", "A"]]}
+        ]
+      },
+      {
+        "source": "emails[not type eq \"work\"] co \"A\"",
+        "target": [
+          {"emails": [{"type": ["not", "eq", "work"]}, ["co", "A"]]}
+        ]
+      },
+      {
+        "source": "not emails[not type eq \"work\"] co \"A\"",
+        "target": [
+          {"emails": [{"type": ["not", "eq", "work"]}, ["not", "co", "A"]]}
+        ]
+      },
+      {
+        "source": "userType eq \"Employee\" and emails[type eq \"work\" or primary eq true]",
+        "target": [
+          {"userType": ["eq", "Employee"], "emails": {"type": ["eq", "work"]}},
+          {"userType": ["eq", "Employee"], "emails": {"primary": ["eq", true]}}
+        ]
+      },
+      {
+        "source": "userType eq \"Employee\" and emails[type eq \"work\" or primary eq true] pr",
+        "target": [
+          {"userType": ["eq", "Employee"], "emails": [{"type": ["eq", "work"]}, ["pr"]]},
+          {"userType": ["eq", "Employee"], "emails": [{"primary": ["eq", true]}, ["pr"]]}
+        ]
+      },
       {
         "source": "(name.FamilyName eq \"Employee\" or name.FamilyName eq \"Manager\") and (emails.Value co \"example.com\" or emails.Value co \"example.org\")",
         "target": [
@@ -178,7 +228,8 @@
       {"source": [{"quota": ["gt", 1.5]}], "target": "quota gt 1.5"},
       {"source": [{"userType": ["eq", null]}], "target": "userType eq null"},
       {"source": [{"active": ["eq", false]}], "target": "active eq false"},
-      {"source": [{"emails": {"primary": ["eq", true]}}], "target": "emails.primary eq true"}
+      {"source": [{"emails": {"primary": ["eq", true]}}], "target": "emails.primary eq true"},
+      {"source": [{"date": ["ge", "2021-09-08T23:02:28.986Z"]}], "target": "date ge \"2021-09-08T23:02:28.986Z\""}
     ],
     "logical": [
       {"source": [{"eq": ["not", "pr"]}], "target": "not eq pr"},
@@ -196,6 +247,19 @@
       {"source": [{"not": ["not", "sw", "A"], "userName": [["ew", "Z"], ["co", "m"]]}], "target": "not not sw \"A\" and userName ew \"Z\" and userName co \"m\""}
     ],
     "complex": [
+      {
+        "target": "emails[type eq \"work\"] pr",
+        "source": [
+          {"emails": [{"type": ["eq", "work"]}, ["pr"]]}
+        ]
+      },
+      {
+        "target": "userType eq \"Employee\" and emails[type eq \"work\"] pr or userType eq \"Employee\" and emails[primary eq true] pr",
+        "source": [
+          {"userType": ["eq", "Employee"], "emails": [{"type": ["eq", "work"]}, ["pr"]]},
+          {"userType": ["eq", "Employee"], "emails": [{"primary": ["eq", true]}, ["pr"]]}
+        ]
+      },
       {
         "target": "name.familyName eq \"Employee\" and emails.value co \"example.com\" or name.familyName eq \"Employee\" and emails.value co \"example.org\" or name.familyName eq \"Manager\" and emails.value co \"example.com\" or name.familyName eq \"Manager\" and emails.value co \"example.org\"",
         "source": [
@@ -296,13 +360,15 @@
         {"expression": {"exists": ["ne", null]}, "expected": [1, 3]},
         {"expression": {"exists": ["eq", true]}, "expected": [1]},
         {"expression": {"exists": ["eq", "True"]}, "expected": [1]},
-        {"expression": {"exists": ["eq", "False"]}, "expected": [3]}
+        {"expression": {"exists": ["eq", "False"]}, "expected": [3]},
+        {"expression": {"exists": ["eq", "falsey"]}, "expected": []}
       ],
       "nesting": [
         {"expression": {"name": {"formatted": ["co", "a"]}}, "expected": [1, 2, 4]},
         {"expression": {"name": {"formatted": ["ew", "e"]}}, "expected": [1, 2]},
         {"expression": {"userName": ["co", "A"], "name": {"formatted": ["co", "a"]}}, "expected": [1, 2]},
         {"expression": {"emails": {"type": ["eq", "work"]}}, "expected": [1, 3, 4]},
+        {"expression": {"emails": [{"type": ["eq", "work"]}, ["pr"]]}, "expected": [1, 3, 4]},
         {"expression": {"emails": {"value": ["ew", "example.net"]}}, "expected": [1, 4]},
         {"expression": {"emails": {"type": ["eq", "work"], "value": ["ew", "example.net"]}}, "expected": [1]}
       ],