forked from google/android-fhir
-
Notifications
You must be signed in to change notification settings - Fork 0
/
LocalChangeUtils.kt
180 lines (166 loc) · 6.3 KB
/
LocalChangeUtils.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http:https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.fhir.db.impl.dao
import ca.uhn.fhir.parser.IParser
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.github.fge.jsonpatch.JsonPatch
import com.github.fge.jsonpatch.diff.JsonDiff
import com.google.android.fhir.LocalChange
import com.google.android.fhir.db.impl.entities.LocalChangeEntity
import com.google.android.fhir.db.impl.entities.LocalChangeEntity.Type
import org.hl7.fhir.r4.model.Resource
import org.json.JSONArray
import org.json.JSONObject
internal object LocalChangeUtils {
/** Squash the changes by merging them two at a time. */
fun squash(localChangeEntities: List<LocalChangeEntity>): LocalChangeEntity =
localChangeEntities.reduce { first, second -> mergeLocalChanges(first, second) }
fun mergeLocalChanges(first: LocalChangeEntity, second: LocalChangeEntity): LocalChangeEntity {
// TODO (maybe this should throw exception when two entities don't have the same versionID)
val type: Type
val payload: String
when (second.type) {
Type.UPDATE ->
when (first.type) {
Type.UPDATE -> {
type = Type.UPDATE
payload = mergePatches(first.payload, second.payload)
}
Type.INSERT -> {
type = Type.INSERT
payload = applyPatch(first.payload, second.payload)
}
else -> {
throw IllegalArgumentException(
"Cannot merge local changes with type ${first.type} and ${second.type}."
)
}
}
Type.DELETE -> {
type = Type.DELETE
payload = ""
}
Type.INSERT -> {
type = Type.INSERT
payload = second.payload
}
}
return LocalChangeEntity(
id = 0,
resourceId = second.resourceId,
resourceType = second.resourceType,
type = type,
payload = payload,
versionId = second.versionId,
timestamp = second.timestamp
)
}
/** Update a JSON object with a JSON patch (RFC 6902). */
private fun applyPatch(resourceString: String, patchString: String): String {
val objectMapper = ObjectMapper()
val resourceJson = objectMapper.readValue(resourceString, JsonNode::class.java)
val patchJson = objectMapper.readValue(patchString, JsonPatch::class.java)
return patchJson.apply(resourceJson).toString()
}
/** Merge two JSON patch strings by concatenating their elements into a new JSON array. */
private fun mergePatches(firstPatch: String, secondPatch: String): String {
// TODO: validate patches are RFC 6902 compliant JSON patches
val firstMap = JSONArray(firstPatch).patchMergeMap()
val secondMap = JSONArray(secondPatch).patchMergeMap()
firstMap.putAll(secondMap)
return JSONArray(firstMap.values).toString()
}
/** Calculates the JSON patch between two [Resource] s. */
internal fun diff(parser: IParser, source: Resource, target: Resource): JSONArray {
val objectMapper = ObjectMapper()
return getFilteredJSONArray(
JsonDiff.asJson(
objectMapper.readValue(parser.encodeResourceToString(source), JsonNode::class.java),
objectMapper.readValue(parser.encodeResourceToString(target), JsonNode::class.java)
)
)
}
/**
* Creates a mutable map from operation type (e.g. add/remove) + property path to the entire
* operation containing the updated value. Two such maps can be merged using `Map.putAll()` to
* yield a minimal set of operations equivalent to individual patches.
*/
private fun JSONArray.patchMergeMap(): MutableMap<Pair<String, String>, JSONObject> {
return (0 until this.length())
.map { this.optJSONObject(it) }
.associateBy { it.optString("op") to it.optString("path") }
.toMutableMap()
}
/**
* This function returns the json diff as a json array of operation objects. We remove the "/meta"
* and "/text" paths as they cause path not found issue when we update the resource. They are
* usually present in the downloaded resource object but are missing in the edited object as these
* aren't supposed to be edited. Thus, the Json diff creates a DELETE- OP for "/meta" and "/text"
* and causes the issue with server update.
*
* An unfiltered JSON Array for family name update looks like
* ```
* [{"op":"remove","path":"/meta"}, {"op":"remove","path":"/text"},
* {"op":"replace","path":"/name/0/family","value":"Nucleus"}]
* ```
*
* A filtered JSON Array for family name update looks like
* ```
* [{"op":"replace","path":"/name/0/family","value":"Nucleus"}]
* ```
*/
private fun getFilteredJSONArray(jsonDiff: JsonNode) =
with(JSONArray(jsonDiff.toString())) {
val ignorePaths = setOf("/meta", "/text")
return@with JSONArray(
(0 until length())
.map { optJSONObject(it) }
.filterNot { jsonObject ->
ignorePaths.any { jsonObject.optString("path").startsWith(it) }
}
)
}
}
/** Method to convert LocalChangeEntity to LocalChange instance. */
internal fun LocalChangeEntity.toLocalChange(): LocalChange {
return LocalChange(
resourceType,
resourceId,
versionId,
timestamp,
LocalChange.Type.from(type.value),
payload,
LocalChangeToken(listOf(id))
)
}
data class LocalChangeToken(val ids: List<Long>)
internal data class SquashedLocalChange(
val token: LocalChangeToken,
val localChange: LocalChangeEntity
)
/** Method to convert internal SquashedLocalChange to LocalChange instance. */
internal fun SquashedLocalChange.toLocalChange(): LocalChange {
return LocalChange(
localChange.resourceType,
localChange.resourceId,
localChange.versionId,
localChange.timestamp,
LocalChange.Type.from(localChange.type.value),
localChange.payload,
token
)
}