Skip to content

Commit

Permalink
implement reference utilities (like the ones in the C# impl.)
Browse files Browse the repository at this point in the history
  • Loading branch information
dslmeinte committed Oct 8, 2024
1 parent a54a16b commit 6a698da
Show file tree
Hide file tree
Showing 7 changed files with 317 additions and 3 deletions.
24 changes: 24 additions & 0 deletions packages/test/src/languages/generic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {chain, concatenator, LanguageFactory, lastOf} from "@lionweb/core"
import {hasher} from "@lionweb/utilities"


const factory = new LanguageFactory(
"Generic",
"0",
chain(concatenator("-"), hasher({ encoding: "base64" })),
lastOf
)
export const genericLanguage = factory.language

export const SomeConcept = factory.concept("SomeConcept", false)
export const AnotherConcept = factory.concept("AnotherConcept", false)

export const SomeConcept_ref = factory.reference(SomeConcept, "ref").ofType(AnotherConcept).isOptional()
export const SomeConcept_refs = factory.reference(SomeConcept, "refs").ofType(AnotherConcept).isMultiple()
SomeConcept.havingFeatures(SomeConcept_ref, SomeConcept_refs)

export const SomeAnnotation = factory.annotation("SomeAnnotation")
SomeAnnotation.annotates = AnotherConcept
export const SomeAnnotation_ref = factory.reference(SomeAnnotation, "ref").ofType(SomeConcept)
SomeAnnotation.havingFeatures(SomeAnnotation_ref)

17 changes: 17 additions & 0 deletions packages/test/src/languages/tiny-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {chain, concatenator, LanguageFactory, lastOf} from "@lionweb/core"
import {hasher} from "@lionweb/utilities"


const factory = new LanguageFactory(
"TinyRef",
"0",
chain(concatenator("-"), hasher({ encoding: "base64" })),
lastOf
)
export const tinyRefLanguage = factory.language

export const MyConcept = factory.concept("MyConcept", false)
export const MyConcept_singularRef = factory.reference(MyConcept, "singularRef").ofType(MyConcept)
export const MyConcept_multivaluedRef = factory.reference(MyConcept, "multivaluedRef").ofType(MyConcept).isMultiple()
MyConcept.havingFeatures(MyConcept_singularRef, MyConcept_multivaluedRef)

164 changes: 164 additions & 0 deletions packages/test/src/m1/reference-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import {assert} from "chai"
const {deepEqual} = assert

import {Classifier, dynamicExtractionFacade, dynamicInstantiationFacade, DynamicNode} from "@lionweb/core"
import {incomingReferences, referencesToOutOfScopeNodes, ReferenceValue, referenceValues} from "@lionweb/utilities"

import {AnotherConcept, SomeAnnotation, SomeAnnotation_ref, SomeConcept, SomeConcept_ref} from "../languages/generic.js"
import {MyConcept, MyConcept_multivaluedRef, MyConcept_singularRef} from "../languages/tiny-ref.js"


/*
* These unit tests are pretty much a straight-up copy of the ones in the ReferenceUtilsTests class in the LionWeb C# implementation:
* https://github.com/LionWeb-io/lionweb-csharp/blob/main/test/LionWeb-CSharp-Test/tests/ReferenceUtilsTests.cs .
*/

describe("reference utils", () => {

const createNode = (id: string, classifier: Classifier): DynamicNode => ({
id,
classifier,
settings: {},
annotations: []
})
const setValue = dynamicInstantiationFacade.setFeatureValue

it("find a reference from a feature of a concept", () => {
const targetNode = createNode("target", AnotherConcept)
const sourceNode = createNode("source", SomeConcept)
setValue(sourceNode, SomeConcept_ref, targetNode)
const scope = [sourceNode, targetNode]

const expectedRefs = [
new ReferenceValue(sourceNode, targetNode, SomeConcept_ref, null)
]
deepEqual(referenceValues(scope, dynamicExtractionFacade), expectedRefs)
deepEqual(incomingReferences(targetNode, scope, dynamicExtractionFacade), expectedRefs)
})

it("find a reference from an annotation", () => {
const targetNode = createNode("target", SomeConcept)
const sourceNode = createNode("source", SomeAnnotation)
setValue(sourceNode, SomeAnnotation_ref, targetNode)
const sourceContainer = createNode("sourceContainer", AnotherConcept)
sourceNode.annotations.push(sourceNode)
const scope = [sourceContainer, sourceNode, targetNode]

/*
* Note: this doesn't actually test anything new relative to the 1st test,
* because the annotation [instance] has a reference just like a concept instance has,
* and because the annotation is explicitly declared as part of the scope.
* It's more interesting to verify that a scope computed from root nodes would contain the annotation!
*/
deepEqual(
incomingReferences(targetNode, scope, dynamicExtractionFacade),
[
new ReferenceValue(sourceNode, targetNode, SomeAnnotation_ref, null)
]
)
})

it("find a reference to itself", () => {
const node = createNode("node", MyConcept)
setValue(node, MyConcept_singularRef, node)

deepEqual(
incomingReferences(node, [node], dynamicExtractionFacade),
[
new ReferenceValue(node, node, MyConcept_singularRef, null)
]
)
})

it("find references in different features of the source", () => {
const targetNode = createNode("target", MyConcept)
const sourceNode = createNode("source", MyConcept)
setValue(sourceNode, MyConcept_singularRef, targetNode)
setValue(sourceNode, MyConcept_multivaluedRef, targetNode)
deepEqual(sourceNode.settings["multivaluedRef"], [targetNode]) // assert that setValue(<node>, <multivalued feature>, <value>) _added_ the value

deepEqual(
incomingReferences(targetNode, [sourceNode], dynamicExtractionFacade),
[
new ReferenceValue(sourceNode, targetNode, MyConcept_singularRef, null),
new ReferenceValue(sourceNode, targetNode, MyConcept_multivaluedRef, 0)
]
)
})

it("find multiple references to target in a multivalued feature of the source", () => {
const targetNode = createNode("target", MyConcept)
const sourceNode = createNode("source", MyConcept)
setValue(sourceNode, MyConcept_multivaluedRef, targetNode)
setValue(sourceNode, MyConcept_multivaluedRef, targetNode)
deepEqual(sourceNode.settings["multivaluedRef"], [targetNode, targetNode]) // assert that setValue(<node>, <multivalued feature>, <value>) _added_ the values

deepEqual(
incomingReferences(targetNode, [sourceNode], dynamicExtractionFacade),
[
new ReferenceValue(sourceNode, targetNode, MyConcept_multivaluedRef, 0),
new ReferenceValue(sourceNode, targetNode, MyConcept_multivaluedRef, 1)
]
)
})

it("find references among multiple sources and targets", () => {
const sourceNode1 = createNode("sourceNode1", MyConcept)
const sourceNode2 = createNode("sourceNode2", MyConcept)
const targetNode1 = createNode("targetNode1", MyConcept)
const targetNode2 = createNode("targetNode2", MyConcept)
setValue(sourceNode1, MyConcept_singularRef, targetNode1)
setValue(sourceNode2, MyConcept_singularRef, targetNode2)

deepEqual(
incomingReferences([targetNode1, targetNode2], [sourceNode1, sourceNode2], dynamicExtractionFacade),
[
new ReferenceValue(sourceNode1, targetNode1, MyConcept_singularRef, null),
new ReferenceValue(sourceNode2, targetNode2, MyConcept_singularRef, null)
]
)
})

it("have defined behavior for duplicate target nodes", () => {
const targetNode = createNode("target", AnotherConcept)
const sourceNode = createNode("source", SomeConcept)
setValue(sourceNode, SomeConcept_ref, targetNode)
const scope = [sourceNode, targetNode]

const expectedRefs = [
new ReferenceValue(sourceNode, targetNode, SomeConcept_ref, null)
]
const duplicateTargetNodes = [targetNode, targetNode]
deepEqual(incomingReferences(duplicateTargetNodes, scope, dynamicExtractionFacade), expectedRefs)
deepEqual(referenceValues(scope, dynamicExtractionFacade), expectedRefs)
})

it("have defined behavior when duplicate nodes in scope", () => {
const targetNode = createNode("target", AnotherConcept)
const sourceNode = createNode("source", SomeConcept)
setValue(sourceNode, SomeConcept_ref, targetNode)
const scope = [sourceNode, targetNode]

const expectedRefs = [
new ReferenceValue(sourceNode, targetNode, SomeConcept_ref, null)
]
const duplicateScope = [...scope, ...scope]
deepEqual(incomingReferences(targetNode, duplicateScope, dynamicExtractionFacade), expectedRefs)
deepEqual(referenceValues(duplicateScope, dynamicExtractionFacade), expectedRefs)
})

it("find unreachable nodes", () => {
const targetNode = createNode("target", AnotherConcept)
const sourceNode = createNode("source", SomeConcept)
setValue(sourceNode, SomeConcept_ref, targetNode)

deepEqual(
referencesToOutOfScopeNodes([sourceNode, sourceNode], dynamicExtractionFacade), // Note: scope is duplicate
[
new ReferenceValue(sourceNode, targetNode, SomeConcept_ref, null)
]
)
})

})

4 changes: 4 additions & 0 deletions packages/utilities/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ It contains utilities on top of the `core` package, such as:

* Make `withoutAnnotations` _not_ modify the original serialization chunk.
* (Use the `littoral-templates` package for textualization — of M2s, so far. This is a technical change, not a functional one, except for maybe some extra whitespace.)
* Add reference utilities that all return `ReferenceValue` objects:
* `referenceValues(<source node>, <extraction facade>)`: all references within the given scope.
* `incomingReferences(<target node(s)>, <search scope>, <extraction facade>)`: all (unique) references coming into the given target node(s) from the search scope.
* `referencesToOutOfScopeNodes(<scope>, <extraction facade>)` all reference targets that are not in the given scope.

### 0.6.8

Expand Down
4 changes: 2 additions & 2 deletions packages/utilities/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export * from "./hashing.js"
export * from "./utils/json.js"
export * from "./m1/reference-utils.js"
export * from "./m3/index.js"
export * from "./serialization/index.js"
export * from "./m3/infer-languages.js"
export * from "./utils/json.js"
104 changes: 104 additions & 0 deletions packages/utilities/src/m1/reference-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {allFeaturesOf, ExtractionFacade, Node, Reference} from "@lionweb/core"


/**
* Represents information about a source and target node related through a {@link Reference}.
* An index of `null` means that the reference is (at most) single-valued.
*/
export class ReferenceValue<NT extends Node> {
constructor(
public readonly sourceNode: NT,
public readonly targetNode: NT,
public readonly reference: Reference,
public readonly index: number | null
) {}
}


/**
* Finds all references within the given scope, as {@link ReferenceValue reference values}.
* To search within all nodes under a collection of root nodes,
* use _child extraction_ to compute all nodes in the forest hanging off of those root nodes as scope.
* Note that any reference is found uniquely,
* i.e. the returned {@link ReferenceValue reference values} are pairwise distinct,
* even if the scope passed contains duplicate nodes.
*
* @param scope - the {@link Node nodes} that are searched for references
* @param extractionFacade - an {@link ExtractionFacade} to reflect on nodes.
* _Note_ that it's assumed that its {@link getFeatureValue} function doesn't throw.
*/
export const referenceValues = <NT extends Node>(
scope: NT[],
extractionFacade: ExtractionFacade<NT>
): ReferenceValue<NT>[] => {
const visit = (sourceNode: NT, reference: Reference): ReferenceValue<NT>[] => {
if (reference.multiple) {
const targetNodes = extractionFacade.getFeatureValue(sourceNode, reference) as NT[] ?? []
return targetNodes
.map((targetNode, index) =>
new ReferenceValue<NT>(sourceNode, targetNode, reference, index)
)
}

const targetNode = extractionFacade.getFeatureValue(sourceNode, reference) as (NT | undefined)
if (targetNode !== undefined) {
return [new ReferenceValue<NT>(sourceNode, targetNode, reference, null)]
}

return []
}

return [...new Set(scope)] // ~ .distinct()
.flatMap((sourceNode) =>
allFeaturesOf(extractionFacade.classifierOf(sourceNode))
.filter((feature) => feature instanceof Reference)
.map((feature) => feature as Reference)
.flatMap((reference) => visit(sourceNode, reference))
)
}


/**
* Finds all references coming into the given target node or any of the given target nodes,
* within the given scope, as {@link ReferenceValue reference values}.
* To search within all nodes under a collection of root nodes,
* use _child extraction_ to compute all nodes in the forest hanging off of those root nodes as scope.
* Note that any reference is found uniquely,
* i.e. the returned {@link ReferenceValue reference values} are pairwise distinct,
* even if the given target nodes or scope contain duplicate nodes.
*
* @param targetNodeOrNodes - one or more target {@link Node nodes} for which the incoming references are searched
* @param scope - the {@link Node nodes} that are searched for references
* @param extractionFacade - an {@link ExtractionFacade} to reflect on nodes.
* _Note_ that it's assumed that its {@link getFeatureValue} function doesn't throw.
*/
export const incomingReferences = <NT extends Node>(
targetNodeOrNodes: NT[] | NT,
scope: NT[],
extractionFacade: ExtractionFacade<NT>
): ReferenceValue<NT>[] => {
const targetNodes = Array.isArray(targetNodeOrNodes) ? targetNodeOrNodes : [targetNodeOrNodes]
return referenceValues(scope, extractionFacade)
.filter((referenceValue) => targetNodes.indexOf(referenceValue.targetNode) > -1)
}


/**
* Finds all references to nodes that are not in the given scope, as {@link ReferenceValue reference values}.
* To search within all nodes under a collection of root nodes,
* use _child extraction_ to compute all nodes in the forest hanging off of those root nodes as scope.
* Note that any reference is found uniquely,
* i.e. the returned {@link ReferenceValue reference values} are pairwise distinct,
* even if the given scope contains duplicate nodes.
*
* @param scope - the {@link Node nodes} that form the scope of “reachable” nodes
* @param extractionFacade - an {@link ExtractionFacade} to reflect on nodes.
* _Note_ that it's assumed that its {@link getFeatureValue} function doesn't throw.
*/
export const referencesToOutOfScopeNodes = <NT extends Node>(
scope: NT[],
extractionFacade: ExtractionFacade<NT>
): ReferenceValue<NT>[] =>
referenceValues(scope, extractionFacade)
.filter((referenceValue) => scope.indexOf(referenceValue.targetNode) === -1)

3 changes: 2 additions & 1 deletion packages/utilities/src/m3/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./diagrams/Mermaid-generator.js"
export * from "./diagrams/PlantUML-generator.js"
export * from "./ts-generation/ts-types-generator.js"
export * from "./infer-languages.js"
export * from "./textualizer.js"
export * from "./ts-generation/ts-types-generator.js"

0 comments on commit 6a698da

Please sign in to comment.