Skip to content

Commit

Permalink
Full scope search for named references (#1600)
Browse files Browse the repository at this point in the history
Adds support for default values that are named references.  For any property with a default value that is a named reference, a managed struct will return the referenced value until set explicitly.

Adds logic that searches for properties by name in any owner of a managed value.  This allows conformance expression constraints and default values to reference values by name so long as they are visible in any object in the ownership hierarchy.  Previously we only supported referencing values that were siblings.

I believe this is the last feature we need to support 100% of the data model semantics used in the Matter specifications (as of Matter 1.4).
  • Loading branch information
lauckhart authored Jan 1, 2025
1 parent 8b9a54b commit 22e7c10
Show file tree
Hide file tree
Showing 18 changed files with 277 additions and 70 deletions.
2 changes: 0 additions & 2 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@
"extendDefaults": true
}
],
// turn on errors for missing imports
"import/no-unresolved": "error",

"@typescript-eslint/require-await": "off",

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The main work (all changes without a GitHub username in brackets in the below li
## __WORK IN PROGRESS__

- @matter/node
- Feature: Constraint and conformance expressions may now reference values by name in any owner of a constrained value
- Enhancement: Each new PASE session now automatically arms the failsafe timer for 60s as required by specs
- Enhancement: Optimizes Node shutdown logic to close sessions and subscriptions before shutting down the network
- Fix: Fixes withBehaviors() method on endpoints
Expand All @@ -34,6 +35,7 @@ The main work (all changes without a GitHub username in brackets in the below li
- @matter/model
- Feature: The constraint evaluator now supports simple mathematical expressions
- Feature: The constraint evaluator now supports limits on the number of Unicode codepoints in a string
- Feature: Default values may now be a reference to another field

- @project-chip/matter.js
- Feature: (Breaking) Added Fabric Label for Controller as required property to initialize the Controller
Expand Down
8 changes: 4 additions & 4 deletions packages/model/src/aspects/Constraint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ export class Constraint extends Aspect<Constraint.Definition> implements Constra
/**
* Test a value against a constraint. Does not recurse into arrays.
*/
test(value: FieldValue, properties?: Record<string, any>): boolean {
// Helper that looks up "reference" field values in properties. This is for constraints such as "min FieldName"
test(value: FieldValue, nameResolver?: (name: string) => unknown): boolean {
// Expression evaluator. This is for constraints such as "min FieldName"
function valueOf(value: Constraint.Expression | undefined, raw = false): FieldValue | undefined {
if (!raw && (typeof value === "string" || Array.isArray(value))) {
return value.length;
Expand All @@ -102,7 +102,7 @@ export class Constraint extends Aspect<Constraint.Definition> implements Constra
switch (type) {
case FieldValue.reference:
if (typeof value.name === "string") {
value = valueOf(properties?.[camelize(value.name)], raw);
value = FieldValue(nameResolver?.(camelize(value.name)));
}
break;

Expand Down Expand Up @@ -168,7 +168,7 @@ export class Constraint extends Aspect<Constraint.Definition> implements Constra
}
}

if (this.parts?.every(part => part.test(value, properties) === false)) {
if (this.parts?.every(part => part.test(value, nameResolver) === false)) {
return false;
}

Expand Down
25 changes: 25 additions & 0 deletions packages/model/src/common/FieldValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,31 @@ export type FieldValue =
| FieldValue.Bytes
| FieldValue.None;

/**
* Create a FieldValue (or undefined) from a naked JavaScript value.
*
* Assumes that objects and arrays already contain valid FieldValues.
*/
export function FieldValue(value: unknown): FieldValue | undefined {
if (typeof value === "function") {
throw new UnexpectedDataError("Cannot cast function to FieldValue");
}

if (typeof value === "object" && value !== null) {
if (Array.isArray(value)) {
return value as FieldValue[];
}

if (value instanceof Date) {
return value;
}

return value as FieldValue.Properties;
}

return value as FieldValue;
}

export namespace FieldValue {
// Typing with constants should be just as type safe as using an enum but simplifies type definitions

Expand Down
31 changes: 27 additions & 4 deletions packages/model/src/logic/definition-validation/ValueValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,15 @@ export class ValueValidator<T extends ValueModel> extends ModelValidator<T> {
// Feature lookup
const cluster = this.model.owner(ClusterModel);
return !!cluster?.features.find(f => f.name === name);
} else {
// Field lookup
return !!this.model.parent?.member(name);
}

// Field lookup
for (let model = this.model.parent; model; model = model.parent) {
if (model.member(name) !== undefined) {
return true;
}
}
return false;
});

this.validateAspect("constraint");
Expand Down Expand Up @@ -98,15 +103,33 @@ export class ValueValidator<T extends ValueModel> extends ModelValidator<T> {
return;
}

// Convert value to proper type if possible
// Special case for string "empty"
if (metatype === Metatype.string && defaultValue === "empty") {
// Metatype doesn't handle this case because otherwise you'd never be able to have a string called "empty".
// In this case though the data likely comes from the spec so we're going to take a flyer and say you can
// never have "empty" as a default value
delete this.model.default;
return;
}

// Attempt to cast to correct value
const cast = FieldValue.cast(metatype, defaultValue);

// Special case for field names
if (typeof defaultValue === "string") {
// Here we are converting any exact match of a default value to a field name to be a dynamic default
// referencing the named field. If we ever have a default value that is the same as a field name then this
// will be incorrect but likely we never will as string defaults are uncommon
let referenced = this.model?.member(defaultValue);
if (referenced === undefined) {
referenced = this.model.owner(ClusterModel)?.member(defaultValue);
}
if (referenced instanceof ValueModel && referenced.effectiveType === this.model.effectiveType) {
this.model.default = FieldValue.Reference(referenced.name);
return;
}
}

if (cast === FieldValue.Invalid) {
this.error("INVALID_VALUE", `Value "${defaultValue}" is not a ${metatype}`);
return;
Expand Down
3 changes: 3 additions & 0 deletions packages/model/src/models/ScopeModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export abstract class ScopeModel<T extends BaseElement = BaseElement> extends Mo

readonly isScope = true;

/**
* Obtain the {@link Scope} for this model.
*/
get scope() {
if (this.#operationalScope !== undefined) {
return this.#operationalScope;
Expand Down
2 changes: 1 addition & 1 deletion packages/model/src/models/ValueModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { BaseElement, ValueElement } from "../elements/index.js";
import { ModelTraversal } from "../logic/ModelTraversal.js";
import { Aspects } from "./Aspects.js";
import { Model } from "./Model.js";
import { PropertyModel } from "./PropertyModel.js";
import type { PropertyModel } from "./PropertyModel.js";

// These are circular dependencies so just to be safe we only import the types. We also need the class, though, at
// runtime. So we use the references in the Model.constructors factory pool.
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions packages/node/src/behavior/state/Val.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ export namespace Val {
* The object that owns the root managed value.
*/
rootOwner?: any;

/**
* The parent of this reference, if any.
*/
parent?: Reference;
}

export const properties = Symbol("properties");
Expand Down
12 changes: 7 additions & 5 deletions packages/node/src/behavior/state/managed/ManagedReference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,13 @@ export function ManagedReference(
};

const reference: Val.Reference = {
owner: parent,
get rootOwner() {
return parent.rootOwner;
},

get parent() {
return parent;
},

get value() {
// Authorization is unnecessary here because the reference would not exist if access is unauthorized
Expand All @@ -69,10 +75,6 @@ export function ManagedReference(
location = loc;
},

get rootOwner() {
return parent.rootOwner;
},

set value(newValue: Val) {
if (value === newValue) {
return;
Expand Down
82 changes: 82 additions & 0 deletions packages/node/src/behavior/state/managed/NameResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2022-2024 Matter.js Authors
* SPDX-License-Identifier: Apache-2.0
*/

import { Val } from "#behavior/state/Val.js";
import { RootSupervisor } from "#behavior/supervision/RootSupervisor.js";
import { Schema } from "#behavior/supervision/Schema.js";
import { camelize } from "@matter/general";
import { ClusterModel, Model, ValueModel } from "@matter/model";
import { Internal } from "./Internal.js";

/**
* Obtain a function that returns a visible property by name from the ownership hierarchy of a managed value.
*
* This supports named lookup of a {@link FieldValue.Reference}.
*/
export function NameResolver(
supervisor: RootSupervisor,
model: Model | undefined,
name: string,
): ((val: Val) => Val) | undefined {
if (model === undefined) {
return;
}

// Optimization for root schema
if (
model === supervisor.schema ||
(model.id !== undefined && supervisor.schema.tag === model.tag && supervisor.schema.id === model.id)
) {
if (!supervisor.memberNames.has(name)) {
return;
}
return createDirectResolver();
}

// Only structs may provide named properties
if (!(model instanceof ValueModel) || model.effectiveMetatype !== "object") {
return createIndirectResolver();
}

// Read directly if the named property is supported by this schema. This is not indexed which is fine because:
// 1. The spec uses this very lightly as of 1.4, and
// 2. We only do this once and only for schema that utilizes this feature
if (supervisor.membersOf(model as Schema).find(model => camelize(model.name, false) === name)) {
return createDirectResolver();
}

// Delegate to parent
return createIndirectResolver();

/**
* Create a reader that reads from this value.
*/
function createDirectResolver() {
return (val: Val) => (val as Val.Struct)?.[name];
}

/**
* Create a reader that reads from parent.
*/
function createIndirectResolver() {
const parentSchema = model!.parent;
if (!(parentSchema instanceof ValueModel) && !(parentSchema instanceof ClusterModel)) {
return;
}

const parentReader = NameResolver(supervisor, parentSchema, name);
if (!parentReader) {
return;
}

return (val: Val) => {
const parent = (val as Internal.Collection)?.[Internal.reference]?.parent?.owner;
if (parent) {
return parentReader(parent as Val.Collection);
}
};
}
}
27 changes: 18 additions & 9 deletions packages/node/src/behavior/state/managed/values/StructManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@
*/

import { camelize, GeneratedClass, ImplementationError, isObject } from "#general";
import { Access, ElementTag, Metatype, ValueModel } from "#model";
import { Access, ElementTag, FieldValue, Metatype, ValueModel } from "#model";
import { FabricIndex } from "#types";
import { AccessControl } from "../../../AccessControl.js";
import { PhantomReferenceError, SchemaImplementationError } from "../../../errors.js";
import type { RootSupervisor } from "../../../supervision/RootSupervisor.js";
import type { Schema } from "../../../supervision/Schema.js";
import { RootSupervisor } from "../../../supervision/RootSupervisor.js";
import { Schema } from "../../../supervision/Schema.js";
import type { ValueSupervisor } from "../../../supervision/ValueSupervisor.js";
import { Val } from "../../Val.js";
import { Instrumentation } from "../Instrumentation.js";
import { Internal } from "../Internal.js";
import { ManagedReference } from "../ManagedReference.js";
import { NameResolver } from "../NameResolver.js";
import { PrimitiveManager } from "./PrimitiveManager.js";

const SESSION = Symbol("options");
Expand Down Expand Up @@ -152,14 +153,22 @@ interface Wrapper extends Val.Struct, Internal.Collection {
[AUTHORIZE_READ]: (index: string) => void;
}

function configureProperty(manager: RootSupervisor, schema: ValueModel) {
function configureProperty(supervisor: RootSupervisor, schema: ValueModel) {
const name = camelize(schema.name);

const { access, manage, validate } = manager.get(schema);
const { access, manage, validate } = supervisor.get(schema);

const fabricScopedList =
schema.effectiveAccess.fabric === Access.Fabric.Scoped && schema.effectiveMetatype === Metatype.array;

// We generally do not deal with default values. If the schema defines a default it is assigned before the manager
// is created. The one exception is for field references. These we must look up dynamically at runtime because the
// value should always track the referenced value
let defaultReader: ((val: Val) => Val) | undefined;
if (typeof FieldValue.referenced(schema.default) === "string") {
defaultReader = NameResolver(supervisor, schema.parent, camelize(FieldValue.referenced(schema.default)!));
}

const descriptor: PropertyDescriptor = {
enumerable: true,

Expand Down Expand Up @@ -296,17 +305,17 @@ function configureProperty(manager: RootSupervisor, schema: ValueModel) {
value = struct[name];
}

if (value === undefined) {
return;
}

// Note that we only mask values that are unreadable. This is appropriate when the parent object is
// visible. For direct access to a property we should throw an error but that must be implemented at a
// higher level because we cannot differentiate here
if (!access.mayRead(this[SESSION], this[Internal.reference].location)) {
return undefined;
}

if (value === undefined) {
return defaultReader?.(this);
}

if (value === null) {
return value;
}
Expand Down
Loading

0 comments on commit 22e7c10

Please sign in to comment.