Skip to content

Commit 298fce1

Browse files
authored
Merge pull request #140 from modelix/feature/model-sync-lib
MODELIX-447 model-sync-lib: New component for exporting/importing from model-api to JSON
2 parents 8e5f4e4 + a996e83 commit 298fce1

File tree

14 files changed

+776
-7
lines changed

14 files changed

+776
-7
lines changed

commitlint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ module.exports = {
1414
"model-api",
1515
"model-client",
1616
"model-server",
17+
"model-sync-lib",
1718
"ts-model-api",
1819
],
1920
],

model-api/src/commonMain/kotlin/org/modelix/model/api/INode.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,11 @@ interface INode {
6969
val allChildren: Iterable<INode>
7070

7171
/**
72-
* Moves a child node to the given role and index.
72+
* Moves a node to this node's children with the given role and index.
73+
* The child node can originate from a different parent.
7374
*
7475
* @param role target role
75-
* @param index target index
76+
* @param index target index within the role
7677
* @param child child node to be moved
7778
*/
7879
fun moveChild(role: String?, index: Int, child: INode)
@@ -97,7 +98,7 @@ interface INode {
9798
* Creates and adds a new child node to this node at the specified index.
9899
*
99100
* @param role role, where the node should be added
100-
* @param index index, where the node should be added
101+
* @param index index within the role, where the node should be added
101102
* @param concept reference to a concept, of which the new node is instance of
102103
* @return new child node
103104
*/

model-api/src/commonMain/kotlin/org/modelix/model/data/ModelData.kt

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package org.modelix.model.data
22

33
import kotlinx.serialization.Serializable
4-
import kotlinx.serialization.decodeFromString
54
import kotlinx.serialization.encodeToString
65
import kotlinx.serialization.json.Json
76
import org.modelix.model.api.*
@@ -19,6 +18,10 @@ data class ModelData(
1918
val createdNodes = HashMap<String, Long>()
2019
val pendingReferences = ArrayList<() -> Unit>()
2120
val parentId = ITree.ROOT_ID
21+
if (root.id != null) {
22+
createdNodes[root.id] = parentId
23+
}
24+
setOriginalId(root, t, parentId)
2225
for (nodeData in root.children) {
2326
loadNode(nodeData, t, parentId, createdNodes, pendingReferences)
2427
}
@@ -37,6 +40,7 @@ data class ModelData(
3740
val createdId = t.addNewChild(parentId, nodeData.role, -1, conceptRef)
3841
if (nodeData.id != null) {
3942
createdNodes[nodeData.id] = createdId
43+
setOriginalId(nodeData, t, createdId)
4044
}
4145
for (propertyData in nodeData.properties) {
4246
t.setProperty(createdId, propertyData.key, propertyData.value)
@@ -52,6 +56,15 @@ data class ModelData(
5256
}
5357
}
5458

59+
private fun setOriginalId(
60+
nodeData: NodeData,
61+
t: IWriteTransaction,
62+
nodeId: Long
63+
) {
64+
val key = NodeData.idPropertyKey
65+
t.setProperty(nodeId, key, nodeData.properties[key] ?: nodeData.id)
66+
}
67+
5568
companion object {
5669
private val prettyJson = Json { prettyPrint = true }
5770
fun fromJson(serialized: String): ModelData = Json.decodeFromString(serialized)
@@ -66,7 +79,11 @@ data class NodeData(
6679
val children: List<NodeData> = emptyList(),
6780
val properties: Map<String, String> = emptyMap(),
6881
val references: Map<String, String> = emptyMap()
69-
)
82+
) {
83+
companion object {
84+
const val idPropertyKey = "#mpsNodeId#"
85+
}
86+
}
7087

7188
fun NodeData.uid(model: ModelData): String {
7289
return (model.id ?: throw IllegalArgumentException("Model has no ID")) +
@@ -84,6 +101,6 @@ fun INode.asData(): NodeData = NodeData(
84101
children = allChildren.map { it.asData() }
85102
)
86103

87-
public inline fun <K, V : Any> Iterable<K>.associateWithNotNull(valueSelector: (K) -> V?): Map<K, V> {
104+
inline fun <K, V : Any> Iterable<K>.associateWithNotNull(valueSelector: (K) -> V?): Map<K, V> {
88105
return associateWith { valueSelector(it) }.filterValues { it != null } as Map<K, V>
89106
}

model-client/src/commonMain/kotlin/org/modelix/model/operations/MoveNodeOp.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ class MoveNodeOp(val childId: Long, val targetPosition: PositionInRole) : Abstra
4848
override fun invert(): List<IOperation> {
4949
return listOf(MoveNodeOp(childId, sourcePosition))
5050
}
51+
52+
override fun toString(): String {
53+
return "applied:MoveNodeOp ${childId.toString(16)}: $sourcePosition->$targetPosition"
54+
}
5155
}
5256

5357
override fun captureIntend(tree: ITree, store: IDeserializingKeyValueStore): IOperationIntend {

model-sync-lib/.gitignore

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
.gradle
2+
build/
3+
!gradle/wrapper/gradle-wrapper.jar
4+
!**/src/main/**/build/
5+
!**/src/test/**/build/
6+
7+
### IntelliJ IDEA ###
8+
.idea/modules.xml
9+
.idea/jarRepositories.xml
10+
.idea/compiler.xml
11+
.idea/libraries/
12+
*.iws
13+
*.iml
14+
*.ipr
15+
out/
16+
!**/src/main/**/out/
17+
!**/src/test/**/out/
18+
19+
### Eclipse ###
20+
.apt_generated
21+
.classpath
22+
.factorypath
23+
.project
24+
.settings
25+
.springBeans
26+
.sts4-cache
27+
bin/
28+
!**/src/main/**/bin/
29+
!**/src/test/**/bin/
30+
31+
### NetBeans ###
32+
/nbproject/private/
33+
/nbbuild/
34+
/dist/
35+
/nbdist/
36+
/.nb-gradle/
37+
38+
### VS Code ###
39+
.vscode/
40+
41+
### Mac OS ###
42+
.DS_Store

model-sync-lib/build.gradle.kts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
plugins {
2+
alias(libs.plugins.kotlin.multiplatform)
3+
}
4+
5+
repositories {
6+
mavenCentral()
7+
}
8+
9+
kotlin {
10+
jvm {
11+
jvmToolchain(11)
12+
testRuns["test"].executionTask.configure {
13+
useJUnitPlatform()
14+
}
15+
}
16+
sourceSets {
17+
val commonMain by getting {
18+
dependencies {
19+
implementation(project(":model-api"))
20+
implementation(libs.kotlin.serialization.json)
21+
}
22+
}
23+
24+
val commonTest by getting {
25+
dependencies {
26+
implementation(project(":model-api"))
27+
implementation(libs.kotlin.serialization.json)
28+
implementation(project(":model-client", configuration = "jvmRuntimeElements"))
29+
implementation(kotlin("test"))
30+
}
31+
}
32+
}
33+
}
34+
35+
publishing {
36+
publications {
37+
create<MavenPublication>("maven") {
38+
from(components["kotlin"])
39+
}
40+
}
41+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package org.modelix.model.sync
2+
3+
import org.modelix.model.api.INode
4+
import org.modelix.model.api.serialize
5+
import org.modelix.model.data.ModelData
6+
import org.modelix.model.data.NodeData
7+
import org.modelix.model.data.associateWithNotNull
8+
import java.io.File
9+
10+
class ModelExporter(private val root: INode) {
11+
12+
fun export(outputFile: File) {
13+
val modelData = ModelData(root = root.asExported())
14+
outputFile.parentFile.mkdirs()
15+
outputFile.writeText(modelData.toJson())
16+
}
17+
}
18+
19+
fun INode.asExported() : NodeData {
20+
val idKey = NodeData.idPropertyKey
21+
return NodeData(
22+
id = getPropertyValue(idKey) ?: reference.serialize(),
23+
concept = concept?.getUID(),
24+
role = roleInParent,
25+
properties = getPropertyRoles().associateWithNotNull { getPropertyValue(it) }.filterKeys { it != idKey },
26+
references = getReferenceRoles().associateWithNotNull {
27+
getReferenceTarget(it)?.getPropertyValue(idKey) ?: getReferenceTargetRef(it)?.serialize()
28+
},
29+
children = allChildren.map { it.asExported() }
30+
)
31+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package org.modelix.model.sync
2+
3+
import org.modelix.model.api.*
4+
import org.modelix.model.data.ModelData
5+
import org.modelix.model.data.NodeData
6+
import java.io.File
7+
8+
class ModelImporter(private val root: INode) {
9+
10+
private val originalIdToExisting: MutableMap<String, INode> = mutableMapOf()
11+
private val postponedReferences = ArrayList<() -> Unit>()
12+
private val nodesToRemove = HashSet<INode>()
13+
14+
fun import(jsonFile: File) {
15+
require(jsonFile.exists())
16+
require(jsonFile.extension == "json")
17+
18+
val data = ModelData.fromJson(jsonFile.readText())
19+
import(data)
20+
}
21+
22+
fun import(data: ModelData) {
23+
originalIdToExisting.clear()
24+
postponedReferences.clear()
25+
nodesToRemove.clear()
26+
buildExistingIndex(root)
27+
data.root.originalId()?.let { originalIdToExisting[it] = root }
28+
syncNode(root, data.root)
29+
postponedReferences.forEach { it.invoke() }
30+
nodesToRemove.forEach { it.remove() }
31+
}
32+
33+
private fun syncNode(node: INode, data: NodeData) {
34+
syncProperties(node, data)
35+
syncChildren(node, data)
36+
syncReferences(node, data)
37+
}
38+
39+
private fun syncChildren(node: INode, data: NodeData) {
40+
val allRoles = (data.children.map { it.role } + node.allChildren.map { it.roleInParent }).distinct()
41+
for (role in allRoles) {
42+
val expectedNodes = data.children.filter { it.role == role }
43+
val existingNodes = node.getChildren(role).toList()
44+
45+
// optimization for when there is no change in the child list
46+
// size check first to avoid querying the original ID
47+
if (expectedNodes.size == existingNodes.size && expectedNodes.map { it.originalId() } == existingNodes.map { it.originalId() }) {
48+
existingNodes.zip(expectedNodes).forEach { syncNode(it.first, it.second) }
49+
continue
50+
}
51+
52+
expectedNodes.forEachIndexed { index, expected ->
53+
val nodeAtIndex = node.getChildren(role).toList().getOrNull(index)
54+
val expectedId = checkNotNull(expected.originalId()) { "Specified node '$expected' has no id" }
55+
val expectedConcept = expected.concept?.let { s -> ConceptReference(s) }
56+
val childNode = if (nodeAtIndex?.originalId() != expectedId) {
57+
val existingNode = originalIdToExisting[expectedId]
58+
if (existingNode == null) {
59+
val newChild = node.addNewChild(role, index, expectedConcept)
60+
newChild.setPropertyValue(NodeData.idPropertyKey, expectedId)
61+
originalIdToExisting[expectedId] = newChild
62+
newChild
63+
} else {
64+
node.moveChild(role, index, existingNode)
65+
nodesToRemove.remove(existingNode)
66+
existingNode
67+
}
68+
} else {
69+
nodeAtIndex
70+
}
71+
check(childNode.getConceptReference() == expectedConcept) { "Unexpected concept change" }
72+
73+
syncNode(childNode, expected)
74+
}
75+
76+
nodesToRemove += node.getChildren(role).drop(expectedNodes.size)
77+
}
78+
}
79+
80+
private fun buildExistingIndex(root: INode) {
81+
root.getDescendants(true).forEach {node ->
82+
node.originalId()?.let { originalIdToExisting[it] = node }
83+
}
84+
}
85+
86+
private fun syncProperties(node: INode, nodeData: NodeData) {
87+
if (node.getPropertyValue(NodeData.idPropertyKey) == null) {
88+
node.setPropertyValue(NodeData.idPropertyKey, nodeData.originalId())
89+
}
90+
91+
nodeData.properties.forEach {
92+
if (node.getPropertyValue(it.key) != it.value) {
93+
node.setPropertyValue(it.key, it.value)
94+
}
95+
}
96+
97+
val toBeRemoved = node.getPropertyRoles().toSet()
98+
.subtract(nodeData.properties.keys)
99+
.filter { it != NodeData.idPropertyKey }
100+
toBeRemoved.forEach { node.setPropertyValue(it, null) }
101+
}
102+
103+
private fun syncReferences(node: INode, nodeData: NodeData) {
104+
nodeData.references.forEach {
105+
val expectedTargetId = it.value
106+
val actualTargetId = node.getReferenceTarget(it.key)?.originalId()
107+
if (actualTargetId != expectedTargetId) {
108+
val expectedTarget = originalIdToExisting[expectedTargetId]
109+
if (expectedTarget == null) {
110+
postponedReferences += {
111+
val expectedRefTarget = originalIdToExisting[expectedTargetId]
112+
if (expectedRefTarget == null) {
113+
// The target node is not part of the model. Assuming it exists in some other model we can
114+
// store the reference and try to resolve it dynamically on access.
115+
node.setReferenceTarget(it.key, SerializedNodeReference(expectedTargetId))
116+
} else {
117+
node.setReferenceTarget(it.key, expectedRefTarget)
118+
}
119+
}
120+
} else {
121+
node.setReferenceTarget(it.key, expectedTarget)
122+
}
123+
}
124+
}
125+
val toBeRemoved = node.getReferenceRoles().toSet() - nodeData.references.keys
126+
toBeRemoved.forEach {
127+
val nullReference: INodeReference? = null
128+
node.setReferenceTarget(it, nullReference)
129+
}
130+
}
131+
}
132+
133+
internal fun INode.originalId(): String? {
134+
return this.getPropertyValue(NodeData.idPropertyKey)
135+
}
136+
137+
internal fun NodeData.originalId(): String? {
138+
return properties[NodeData.idPropertyKey] ?: id
139+
}

0 commit comments

Comments
 (0)