diff --git a/engine-kmp/build.gradle.kts b/engine-kmp/build.gradle.kts new file mode 100644 index 0000000000..e4f5011124 --- /dev/null +++ b/engine-kmp/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("com.android.kotlin.multiplatform.library") +} + +kotlin { + jvmToolchain(21) + + androidLibrary { + namespace = "com.google.android.fhir.engine" + compileSdk = Sdk.COMPILE_SDK + minSdk = Sdk.MIN_SDK + } + + jvm("desktop") + + iosX64() + iosArm64() + iosSimulatorArm64() + + targets.configureEach { + compilations.configureEach { + compilerOptions.configure { + freeCompilerArgs.add("-Xexpect-actual-classes") + optIn.addAll( + "kotlin.time.ExperimentalTime", + "kotlin.uuid.ExperimentalUuidApi", + ) + } + } + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlin.fhir) + implementation(libs.fhir.path) + implementation(libs.kermit) + } + } + } +} diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/ContentTypes.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/ContentTypes.kt new file mode 100644 index 0000000000..ee4e4ab2bd --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/ContentTypes.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2025-2026 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://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 + +internal object ContentTypes { + const val APPLICATION_JSON_PATCH = "application/json-patch+json" + const val APPLICATION_FHIR_JSON = "application/fhir+json" +} diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/DateProvider.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/DateProvider.kt new file mode 100644 index 0000000000..63447f10da --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/DateProvider.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2025-2026 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://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 + +import kotlin.time.Clock +import kotlin.time.Instant + +/** The DateProvider instance [FhirEngine] uses for date/time related operations. */ +internal object DateProvider { + private var fixedInstant: Instant? = null + + /** + * Returns the current [Instant]. If a fixed instant has been set via [setFixed], returns that + * instead. + */ + fun now(): Instant = fixedInstant ?: Clock.System.now() + + /** Fixes the clock to always return the given [instant]. */ + fun setFixed(instant: Instant) { + fixedInstant = instant + } + + /** Resets the clock to use the system clock. */ + fun resetClock() { + fixedInstant = null + } +} diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/FhirEngine.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/FhirEngine.kt new file mode 100644 index 0000000000..0e6b7196c8 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/FhirEngine.kt @@ -0,0 +1,253 @@ +/* + * Copyright 2025-2026 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://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 + +import com.google.android.fhir.db.LocalChangeResourceReference +import com.google.android.fhir.db.ResourceNotFoundException +import com.google.android.fhir.search.Search +import com.google.android.fhir.sync.ConflictResolver +import com.google.android.fhir.sync.upload.SyncUploadProgress +import com.google.android.fhir.sync.upload.UploadRequestResult +import com.google.android.fhir.sync.upload.UploadStrategy +import com.google.fhir.model.r4.Resource +import com.google.fhir.model.r4.terminologies.ResourceType +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Instant +import kotlinx.coroutines.flow.Flow + +/** + * Provides an interface for managing FHIR resources in local storage. + * + * The FHIR Engine allows you to create, read, update, and delete (CRUD) FHIR resources, as well as + * perform searches and synchronize data with a remote FHIR server. The FHIR resources are + * represented using Kotlin FHIR Structures [Resource] and [ResourceType]. + * + * To use a FHIR Engine instance, first call [FhirEngineProvider.init] with a + * [FhirEngineConfiguration]. This must be done only once; we recommend doing this in the + * `onCreate()` function of your `Application` class on Android, or during app initialization on + * other platforms. + * + * ``` + * FhirEngineProvider.init( + * FhirEngineConfiguration( + * enableEncryptionIfSupported = true, + * RECREATE_AT_OPEN + * ) + * ) + * ``` + * + * To get a `FhirEngine` to interact with, use [FhirEngineProvider.getInstance]: + * ``` + * val fhirEngine = FhirEngineProvider.getInstance() + * ``` + */ +interface FhirEngine { + /** + * Creates one or more FHIR [Resource]s in the local storage. FHIR Engine requires all stored + * resources to have a logical [Resource.id]. If the `id` is specified in the resource passed to + * [create], the resource created in `FhirEngine` will have the same `id`. If no `id` is + * specified, `FhirEngine` will generate a UUID as that resource's `id` and include it in the + * returned list of IDs. + * + * @param resource The FHIR resources to create. + * @return A list of logical IDs of the newly created resources. + */ + suspend fun create(vararg resource: Resource): List + + /** + * Loads a FHIR resource given its [ResourceType] and logical ID. + * + * @param type The type of the resource to load. + * @param id The logical ID of the resource. + * @return The requested FHIR resource. + * @throws ResourceNotFoundException if the resource is not found. + */ + @Throws(ResourceNotFoundException::class, CancellationException::class) + suspend fun get(type: ResourceType, id: String): Resource + + /** + * Updates one or more FHIR [Resource]s in the local storage. + * + * @param resource The FHIR resources to update. + */ + suspend fun update(vararg resource: Resource) + + /** + * Removes a FHIR resource given its [ResourceType] and logical ID. + * + * @param type The type of the resource to delete. + * @param id The logical ID of the resource. + */ + suspend fun delete(type: ResourceType, id: String) + + /** + * Searches the database and returns a list of resources matching the [Search] specifications. + * + * @param search The search criteria to apply. + * @return A list of [SearchResult] objects containing the matching resources and any included + * references. + */ + suspend fun search(search: Search): List> + + /** + * Synchronizes upload results with the database. + * + * This function initiates multiple server calls to upload local changes. The results of each call + * are emitted as [UploadRequestResult] objects, which can be collected using a [Flow]. + * + * @param uploadStrategy Defines strategies for uploading FHIR resource. + * @param upload A suspending function that takes a list of [LocalChange] objects and returns a + * [Flow] of [UploadRequestResult] objects. + * @return A [Flow] that emits the progress of the synchronization process as [SyncUploadProgress] + * objects. + */ + @Deprecated("To be deprecated.") + suspend fun syncUpload( + uploadStrategy: UploadStrategy, + upload: + (suspend (List, List) -> Flow< + UploadRequestResult, + >), + ): Flow + + /** + * Synchronizes the download results with the database. + * + * This function updates the local database to reflect the results of the download operation, + * resolving any conflicts using the provided [ConflictResolver]. + * + * @param conflictResolver The [ConflictResolver] to use for resolving conflicts between local and + * remote data. + * @param download A suspending function that returns a [Flow] of lists of [Resource] objects + * representing the downloaded data. + */ + @Deprecated("To be deprecated.") + suspend fun syncDownload( + conflictResolver: ConflictResolver, + download: suspend () -> Flow>, + ) + + /** + * Returns the total count of entities available for the given [Search]. + * + * @param search The search criteria to apply. + * @return The total number of matching resources. + */ + suspend fun count(search: Search): Long + + /** + * Returns the timestamp when data was last synchronized, or `null` if no synchronization has + * occurred yet. + */ + suspend fun getLastSyncTimeStamp(): Instant? + + /** + * Clears all database tables without resetting the auto-increment value generated by + * PrimaryKey.autoGenerate. + * + * WARNING: This will permanently delete all data in the database. + */ + suspend fun clearDatabase() + + /** + * Retrieves a list of [LocalChange]s for the [Resource] with the given type and ID. This can be + * used to select resources to purge from the database. + * + * @param type The [ResourceType] of the resource. + * @param id The resource ID. + * @return A list of [LocalChange] objects representing the local changes made to the resource, or + * an empty list if no changes. + */ + suspend fun getLocalChanges(type: ResourceType, id: String): List + + /** + * Purges a resource from the database without deleting data from the server. + * + * @param type The [ResourceType] of the resource. + * @param id The resource ID. + * @param forcePurge If `true`, the resource will be purged even if it has local changes. + * Otherwise, an [IllegalStateException] will be thrown if local changes exist. Defaults to + * `false`. + * + * If you need to purge resources in bulk use the method + * [FhirEngine.purge(type: ResourceType, ids: Set, forcePurge: Boolean = false)] + */ + suspend fun purge(type: ResourceType, id: String, forcePurge: Boolean = false) + + /** + * Purges resources of the specified type from the database identified by their IDs without any + * deletion of data from the server. + * + * @param type The [ResourceType] + * @param ids The resource ids [Set]<[Resource.id]> + * @param forcePurge If `true`, the resource will be purged even if it has local changes. + * Otherwise, an [IllegalStateException] will be thrown if local changes exist. Defaults to + * `false`. + * + * In the case an exception is thrown by any entry in the list the whole transaction is rolled + * back and no record is purged. + */ + suspend fun purge(type: ResourceType, ids: Set, forcePurge: Boolean = false) + + /** + * Adds support for performing actions on `FhirEngine` as a single atomic transaction where the + * entire set of changes succeed or fail as a single entity + */ + suspend fun withTransaction(block: suspend FhirEngine.() -> Unit) +} + +/** + * Retrieves a FHIR resource of type [R] with the given [id] from the local storage. + * + * @param R The type of the FHIR resource to retrieve. + * @param id The logical ID of the resource to retrieve. + * @return The requested FHIR resource. + * @throws ResourceNotFoundException if the resource is not found. + */ +@Throws(ResourceNotFoundException::class, CancellationException::class) +suspend inline fun FhirEngine.get(id: String): R { + return get(getResourceType(R::class), id) as R +} + +/** + * Deletes a FHIR resource of type [R] with the given [id] from the local storage. + * + * @param R The type of the FHIR resource to delete. + * @param id The logical ID of the resource to delete. + */ +suspend inline fun FhirEngine.delete(id: String) { + delete(getResourceType(R::class), id) +} + +typealias SearchParamName = String + +/** + * Represents the result of a FHIR search query, containing a matching resource and any referenced + * resources as specified in the query. + * + * @param R The type of the main FHIR resource in the search result. + * @property resource The FHIR resource that matches the search criteria. + * @property included A map of included resources, keyed by the search parameter name used for + * inclusion, as per the [Search.forwardIncludes] criteria in the query. + * @property revIncluded A map of reverse included resources, keyed by the resource type and search + * parameter name used for inclusion, as per the [Search.revIncludes] criteria in the query. + */ +data class SearchResult( + val resource: R, + val included: Map>?, + val revIncluded: Map, List>?, +) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/FhirEngineConfiguration.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/FhirEngineConfiguration.kt new file mode 100644 index 0000000000..3ed29a6339 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/FhirEngineConfiguration.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2025-2026 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://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 + +import com.google.android.fhir.index.SearchParamDefinition +import com.google.android.fhir.sync.HttpAuthenticator +import com.google.android.fhir.sync.remote.HttpLogger + +/** + * Configuration for the FHIR Engine, including database setup, error recovery, server connection, + * and custom search parameters. + * + * @property enableEncryptionIfSupported Enables database encryption if supported by the platform. + * Defaults to false. + * @property databaseErrorStrategy The strategy to handle database errors. Defaults to + * [DatabaseErrorStrategy.UNSPECIFIED]. + * @property serverConfiguration Optional configuration for connecting to a remote FHIR server. + * @property testMode Whether to run the engine in test mode (using an in-memory database). Defaults + * to false. + * @property customSearchParameters Additional search parameters to be used for querying the FHIR + * engine with the Search API. These are in addition to the default search parameters defined in + * the FHIR specification. Custom search parameters must be unique and not change existing or + * default search parameters. + */ +data class FhirEngineConfiguration( + val enableEncryptionIfSupported: Boolean = false, + val databaseErrorStrategy: DatabaseErrorStrategy = DatabaseErrorStrategy.UNSPECIFIED, + val serverConfiguration: ServerConfiguration? = null, + val testMode: Boolean = false, + val customSearchParameters: List? = null, +) + +/** How database errors should be handled. */ +enum class DatabaseErrorStrategy { + /** + * If unspecified, all database errors will be propagated to the call site. The caller shall + * handle the database error on a case-by-case basis. + */ + UNSPECIFIED, + + /** + * If a database error occurs at open, automatically recreate the database. + * + * This strategy is NOT respected when opening a previously unencrypted database with an encrypted + * configuration or vice versa. An [IllegalStateException] is thrown instead. + */ + RECREATE_AT_OPEN, +} + +/** + * Configuration for connecting to a remote FHIR server. + * + * @property baseUrl The base URL of the remote FHIR server. + * @property networkConfiguration Configuration for network connection parameters. Defaults to + * [NetworkConfiguration]. + * @property authenticator An optional [HttpAuthenticator] for providing HTTP authorization headers. + * @property httpLogger Logs the communication between the engine and the remote server. Defaults to + * [HttpLogger.NONE]. + */ +data class ServerConfiguration( + val baseUrl: String, + val networkConfiguration: NetworkConfiguration = NetworkConfiguration(), + val authenticator: HttpAuthenticator? = null, + val httpLogger: HttpLogger = HttpLogger.NONE, +) + +/** + * Configuration for network connection parameters used when communicating with a remote FHIR + * server. + * + * @property connectionTimeOut Connection timeout in seconds. Defaults to 10 seconds. + * @property readTimeOut Read timeout in seconds for network connections. Defaults to 10 seconds. + * @property writeTimeOut Write timeout in seconds for network connections. Defaults to 10 seconds. + * @property uploadWithGzip Enables compression of requests when uploading to a server that supports + * gzip. Defaults to false. + * @property httpCache Optional [CacheConfiguration] to enable Cache-Control headers for network + * requests. + */ +data class NetworkConfiguration( + val connectionTimeOut: Long = 10, + val readTimeOut: Long = 10, + val writeTimeOut: Long = 10, + val uploadWithGzip: Boolean = false, + val httpCache: CacheConfiguration? = null, +) + +/** + * Configuration for HTTP caching of network requests. + * + * @property cacheDir The directory path used for caching (platform-agnostic string path). + * @property maxSize The maximum size of the cache in bits, e.g., `50L * 1024L * 1024L` for 50 MiB. + */ +data class CacheConfiguration( + val cacheDir: String, + val maxSize: Long, +) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/LocalChange.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/LocalChange.kt new file mode 100644 index 0000000000..a6144a788b --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/LocalChange.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2025-2026 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://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 + +import kotlin.time.Instant + +/** Data class for squashed local changes for resource. */ +data class LocalChange( + /** The resource type name (e.g. "Patient"). */ + val resourceType: String, + /** The resource id. */ + val resourceId: String, + /** The id of the version of the resource that this local change is based on. */ + val versionId: String? = null, + /** The time instant the app user performed a CUD operation on the resource. */ + val timestamp: Instant, + /** Type of local change like insert, delete, etc. */ + val type: Type, + /** JSON string with local changes. */ + val payload: String, + /** + * This token value must be explicitly applied when list of local changes are squashed and + * [LocalChange] class instance is created. + */ + var token: LocalChangeToken, +) { + enum class Type(val value: Int) { + INSERT(1), // create a new resource. payload is the entire resource json. + UPDATE(2), // patch. payload is the json patch. + DELETE(3), // delete. payload is empty string. + ; + + companion object { + fun from(input: Int): Type = entries.first { it.value == input } + } + } +} + +data class LocalChangeToken(val ids: List) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/Logging.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/Logging.kt new file mode 100644 index 0000000000..64a6628857 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/Logging.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2025-2026 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://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 + +import co.touchlab.kermit.Logger + +/** Kermit-based logging for engine-kmp, replacing Timber (Android-only). */ +internal object FhirEngineLog { + private val logger = Logger.withTag("FhirEngine") + + fun w(message: String) { + logger.w { message } + } + + fun d(message: String) { + logger.d { message } + } + + fun e(message: String, throwable: Throwable? = null) { + if (throwable != null) { + logger.e(throwable) { message } + } else { + logger.e { message } + } + } + + fun i(message: String) { + logger.i { message } + } + + fun withTag(tag: String): TaggedLog = TaggedLog(tag) +} + +internal class TaggedLog(tag: String) { + private val logger = Logger.withTag(tag) + + fun w(message: String) { + logger.w { message } + } + + fun d(message: String) { + logger.d { message } + } + + fun e(message: String, throwable: Throwable? = null) { + if (throwable != null) { + logger.e(throwable) { message } + } else { + logger.e { message } + } + } + + fun i(message: String) { + logger.i { message } + } +} diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/MoreResources.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/MoreResources.kt new file mode 100644 index 0000000000..706d7c4b6b --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/MoreResources.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2025-2026 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://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 + +import com.google.fhir.model.r4.Resource +import com.google.fhir.model.r4.terminologies.ResourceType +import kotlin.reflect.KClass + +/** + * Resource type registry mapping KClass to ResourceType. This replaces JVM reflection + * (Class.forName) used in the original engine module with a KMP-compatible lookup. + * + * Note: This registry is populated with common resource types. Additional types can be registered + * via [registerResourceType]. + */ +private val resourceTypeRegistry = mutableMapOf, ResourceType>() + +private val resourceTypeNameRegistry = mutableMapOf>() + +/** Registers a resource KClass with its corresponding [ResourceType]. */ +fun registerResourceType(kClass: KClass, type: ResourceType) { + resourceTypeRegistry[kClass] = type + resourceTypeNameRegistry[type.name] = kClass +} + +/** + * Returns the FHIR [ResourceType] for the given resource [KClass]. + * + * @throws IllegalArgumentException if the class is not registered in the resource type registry + */ +fun getResourceType(kClass: KClass): ResourceType = + resourceTypeRegistry[kClass] + ?: throw IllegalArgumentException( + "Cannot resolve resource type for ${kClass.simpleName}. " + + "Register it with registerResourceType() first.", + ) + +/** + * Returns the [KClass] for the given resource type name. + * + * @throws IllegalArgumentException if the resource type name is not registered + */ +fun getResourceClass(resourceType: String): KClass { + val className = resourceType.replace(Regex("\\{[^}]*\\}"), "") + return resourceTypeNameRegistry[className] + ?: throw IllegalArgumentException( + "Cannot resolve resource class for $className. " + + "Register it with registerResourceType() first.", + ) +} + +/** Returns the [KClass] for the given [ResourceType]. */ +fun getResourceClass(resourceType: ResourceType): KClass = + getResourceClass(resourceType.name) + +/** + * Returns the FHIR resource type name string for this [Resource] (e.g., "Patient", "Observation"). + * + * This uses the Kotlin class simple name which matches the FHIR resource type name for all + * kotlin-fhir model classes. + */ +internal val Resource.resourceType: String + get() = this::class.simpleName ?: error("Cannot determine resource type for $this") diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/UnitConverter.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/UnitConverter.kt new file mode 100644 index 0000000000..d930f562ba --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/UnitConverter.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2025-2026 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://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 + +import com.ionspin.kotlin.bignum.decimal.BigDecimal + +/** + * Canonicalizes unit values to UCUM base units. + * + * For details of UCUM, see http://unitsofmeasure.org/ + * + * For using UCUM with FHIR, see https://www.hl7.org/fhir/ucum.html + * + * TODO: Provide a full UCUM conversion implementation for KMP. Currently returns the original value + * unchanged. + */ +internal object UnitConverter { + + /** + * Returns the canonical form of a UCUM Value. Currently returns the original value as UCUM + * conversion is not yet available for KMP. + */ + fun getCanonicalFormOrOriginal(value: UcumValue): UcumValue = value +} + +internal class ConverterException(message: String, cause: Throwable? = null) : + Exception(message, cause) + +internal data class UcumValue(val code: String, val value: BigDecimal) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/Util.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/Util.kt new file mode 100644 index 0000000000..60adb9b61e --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/Util.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2025-2026 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://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 + +import kotlin.time.Instant +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +/** Formats an [Instant] as an ISO 8601 date-time string with timezone offset. */ +internal fun Instant.toTimeZoneString(): String { + val localDateTime = this.toLocalDateTime(TimeZone.currentSystemDefault()) + return localDateTime.toString() +} + +/** Returns true if given string matches ISO date format i.e. "yyyy-MM-dd", false otherwise. */ +internal fun isValidDateOnly(date: String): Boolean = Regex("^\\d{4}-\\d{2}-\\d{2}$").matches(date) + +/** Implementation of a parallelized map. */ +suspend fun Iterable.pmap( + dispatcher: CoroutineDispatcher, + f: suspend (A) -> B, +): List = coroutineScope { map { async(dispatcher) { f(it) } }.awaitAll() } + +/** Url for the UCUM system of measures. */ +internal const val ucumUrl = "http://unitsofmeasure.org" + +internal fun percentOf(value: Number, total: Number) = + if (total == 0) 0.0 else value.toDouble() / total.toDouble() diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/Database.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/Database.kt new file mode 100644 index 0000000000..fe74801996 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/Database.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2025-2026 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://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 + +import com.google.android.fhir.LocalChange +import com.google.android.fhir.LocalChangeToken +import com.google.android.fhir.search.SearchQuery +import com.google.fhir.model.r4.Resource +import com.google.fhir.model.r4.terminologies.ResourceType +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Instant +import kotlin.uuid.Uuid + +/** The interface for the FHIR resource database. */ +internal interface Database { + /** + * Inserts a list of local `resources` into the FHIR resource database. If any of the resources + * already exists, it will be overwritten. + * + * @return the logical IDs of the newly created resources. + */ + suspend fun insert(vararg resource: R): List + + /** + * Inserts a list of remote `resources` into the FHIR resource database. If any of the resources + * already exists, it will be overwritten. + */ + suspend fun insertRemote(vararg resource: R) + + /** + * Updates the `resource` in the FHIR resource database. If the resource does not already exist, + * then it will not be created. + */ + suspend fun update(vararg resources: Resource) + + /** Updates the `resource` meta in the FHIR resource database. */ + suspend fun updateVersionIdAndLastUpdated( + resourceId: String, + resourceType: ResourceType, + versionId: String?, + lastUpdated: Instant?, + ) + + /** + * Updates the existing [oldResourceId] with the new [newResourceId]. Even if [oldResourceId] and + * [newResourceId] are the same, it is still necessary to update the resource meta. + */ + suspend fun updateResourcePostSync( + oldResourceId: String, + newResourceId: String, + resourceType: ResourceType, + versionId: String?, + lastUpdated: Instant?, + ) + + /** + * Selects the FHIR resource of type with `id`. + * + * @throws ResourceNotFoundException if the resource is not found in the database + */ + @Throws(ResourceNotFoundException::class, CancellationException::class) + suspend fun select(type: ResourceType, id: String): Resource + + /** Insert resources that were synchronized. */ + suspend fun insertSyncedResources(resources: List) + + /** Deletes the FHIR resource of type with `id`. */ + suspend fun delete(type: ResourceType, id: String) + + suspend fun search(query: SearchQuery): List> + + suspend fun count(query: SearchQuery): Long + + /** + * Retrieves all [LocalChange]s for all [Resource]s, which can be used to update the remote FHIR + * server. + */ + suspend fun getAllLocalChanges(): List + + /** + * Retrieves all [LocalChange]s for the [Resource] which has the [LocalChange] with the oldest + * [LocalChange.timestamp]. + */ + suspend fun getAllChangesForEarliestChangedResource(): List + + /** Retrieves the count of [LocalChange]s stored in the database. */ + suspend fun getLocalChangesCount(): Int + + /** Remove the [LocalChange]s with given ids. Call this after a successful sync. */ + suspend fun deleteUpdates(token: LocalChangeToken) + + /** Remove the [LocalChange]s with matching resource ids. */ + suspend fun deleteUpdates(resources: List) + + /** + * Updates the existing resource identified by [currentResourceId] with the [updatedResource], + * ensuring all associated references in the database are also updated accordingly. + */ + suspend fun updateResourceAndReferences( + currentResourceId: String, + updatedResource: Resource, + ) + + /** Runs the block as a database transaction. */ + suspend fun withTransaction(block: suspend () -> Unit) + + /** Closes the database connection. */ + fun close() + + /** Clears all database tables. WARNING: This will clear the database and it's not recoverable. */ + suspend fun clearDatabase() + + /** + * Retrieve a list of [LocalChange] for [Resource] with given type and id. + * + * @return A list of local changes, or an empty list if none exist. + */ + suspend fun getLocalChanges(type: ResourceType, id: String): List + + /** + * Retrieve a list of [LocalChange] for a resource with the given UUID. + * + * @return A list of local changes, or an empty list if none exist. + */ + suspend fun getLocalChanges(resourceUuid: Uuid): List + + /** + * Purges resources of the specified type from the database identified by their IDs without any + * deletion of data from the server. + */ + suspend fun purge(type: ResourceType, ids: Set, forcePurge: Boolean = false) + + /** + * @return List of [LocalChangeResourceReference] associated with the local change IDs. A single + * local change may have one or more [LocalChangeResourceReference] associated with it. + */ + suspend fun getLocalChangeResourceReferences( + localChangeIds: List, + ): List +} + +internal data class ResourceWithUUID( + val uuid: Uuid, + val resource: R, +) + +data class LocalChangeResourceReference( + val localChangeId: Long, + val resourceReferenceValue: String, + val resourceReferencePath: String?, +) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/DatabaseEncryptionException.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/DatabaseEncryptionException.kt new file mode 100644 index 0000000000..b3720e044c --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/DatabaseEncryptionException.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2025-2026 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://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 + +/** + * A database encryption exception wrapper which maps comprehensive keystore errors to a limited set + * of actionable errors. + */ +class DatabaseEncryptionException(cause: Exception, val errorCode: DatabaseEncryptionErrorCode) : + Exception(cause) { + + enum class DatabaseEncryptionErrorCode { + /** Unclassified error. The error could potentially be mitigated by recreating the database. */ + UNKNOWN, + + /** Required encryption algorithm is not available. */ + UNSUPPORTED, + + /** Timeout when accessing encrypted database. */ + TIMEOUT, + } +} diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/ResourceNotFoundException.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/ResourceNotFoundException.kt new file mode 100644 index 0000000000..5476f3abea --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/ResourceNotFoundException.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2025-2026 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://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 + +import kotlin.uuid.Uuid + +/** Thrown to indicate that the requested resource is not found. */ +class ResourceNotFoundException : Exception { + lateinit var type: String + lateinit var id: String + lateinit var uuid: Uuid + + constructor( + type: String, + id: String, + cause: Throwable, + ) : super("Resource not found with type $type and id $id!", cause) { + this.type = type + this.id = id + } + + constructor( + type: String, + id: String, + ) : super("Resource not found with type $type and id $id!") { + this.type = type + this.id = id + } + + constructor( + uuid: Uuid, + ) : super("Resource not found with UUID $uuid!") { + this.uuid = uuid + } +} diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/ResourceNotFoundInDbException.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/ResourceNotFoundInDbException.kt new file mode 100644 index 0000000000..e78cc580fb --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/db/ResourceNotFoundInDbException.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025-2026 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://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 + +/** Exception thrown to indicate that the requested resource is not found in the database. */ +class ResourceNotFoundInDbException(val type: String, val id: String) : + Exception("Resource not found with type $type and id $id!") diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/engine/EngineKmp.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/engine/EngineKmp.kt new file mode 100644 index 0000000000..082d9edde6 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/engine/EngineKmp.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2025-2026 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://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.engine diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/ResourceIndexer.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/ResourceIndexer.kt new file mode 100644 index 0000000000..b38400075a --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/ResourceIndexer.kt @@ -0,0 +1,486 @@ +/* + * Copyright 2025-2026 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://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.index + +import com.google.android.fhir.UcumValue +import com.google.android.fhir.UnitConverter +import com.google.android.fhir.index.entities.DateIndex +import com.google.android.fhir.index.entities.DateTimeIndex +import com.google.android.fhir.index.entities.NumberIndex +import com.google.android.fhir.index.entities.PositionIndex +import com.google.android.fhir.index.entities.QuantityIndex +import com.google.android.fhir.index.entities.ReferenceIndex +import com.google.android.fhir.index.entities.StringIndex +import com.google.android.fhir.index.entities.TokenIndex +import com.google.android.fhir.index.entities.UriIndex +import com.google.android.fhir.resourceType +import com.google.android.fhir.search.LAST_UPDATED +import com.google.android.fhir.search.LOCAL_LAST_UPDATED +import com.google.android.fhir.ucumUrl +import com.google.fhir.fhirpath.FhirPathEngine +import com.google.fhir.model.r4.Address +import com.google.fhir.model.r4.Canonical +import com.google.fhir.model.r4.CodeableConcept +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.DateTime +import com.google.fhir.model.r4.FhirDate +import com.google.fhir.model.r4.FhirDateTime +import com.google.fhir.model.r4.HumanName +import com.google.fhir.model.r4.Id +import com.google.fhir.model.r4.Identifier +import com.google.fhir.model.r4.Location +import com.google.fhir.model.r4.Money +import com.google.fhir.model.r4.Period +import com.google.fhir.model.r4.Quantity +import com.google.fhir.model.r4.Reference +import com.google.fhir.model.r4.Resource +import com.google.fhir.model.r4.Timing +import com.google.fhir.model.r4.Uri +import com.google.fhir.model.r4.terminologies.ResourceType +import com.ionspin.kotlin.bignum.decimal.BigDecimal +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant + +/** + * Indexes a FHIR resource according to the + * [search parameters](https://www.hl7.org/fhir/searchparameter-registry.html). + */ +internal class ResourceIndexer( + private val searchParamDefinitionsProvider: SearchParamDefinitionsProvider, +) { + private val fhirPathEngine = FhirPathEngine.forR4() + + fun index(resource: R) = extractIndexValues(resource) + + private fun extractIndexValues(resource: R): ResourceIndices { + val resourceTypeName = resource.resourceType + val resourceTypeEnum = ResourceType.fromCode(resourceTypeName) + val indexBuilder = + ResourceIndices.Builder(resourceTypeEnum, resource.id ?: error("Resource must have an id")) + searchParamDefinitionsProvider + .get(resource) + .map { it to fhirPathEngine.evaluateExpression(it.path, resource).toList() } + .flatMap { pair -> pair.second.map { pair.first to it } } + .forEach { pair -> + val (searchParam, value) = pair + when (pair.first.type) { + SearchParamType.NUMBER -> + numberIndex(searchParam, value)?.also { indexBuilder.addNumberIndex(it) } + SearchParamType.DATE -> + when (value) { + is com.google.fhir.model.r4.Date -> + dateIndex(searchParam, value)?.also { indexBuilder.addDateIndex(it) } + else -> dateTimeIndex(searchParam, value)?.also { indexBuilder.addDateTimeIndex(it) } + } + SearchParamType.STRING -> + stringIndex(searchParam, value)?.also { indexBuilder.addStringIndex(it) } + SearchParamType.TOKEN -> + tokenIndex(searchParam, value).forEach { indexBuilder.addTokenIndex(it) } + SearchParamType.REFERENCE -> + referenceIndex(searchParam, value)?.also { indexBuilder.addReferenceIndex(it) } + SearchParamType.QUANTITY -> + quantityIndex(searchParam, value).forEach { indexBuilder.addQuantityIndex(it) } + SearchParamType.URI -> uriIndex(searchParam, value)?.also { indexBuilder.addUriIndex(it) } + SearchParamType.SPECIAL -> specialIndex(value)?.also { indexBuilder.addPositionIndex(it) } + // TODO: Handle composite type https://github.com/google/android-fhir/issues/292. + else -> Unit + } + } + + return indexBuilder.build() + } + + companion object { + + private fun numberIndex(searchParam: SearchParamDefinition, value: Any): NumberIndex? = + when (value) { + is com.google.fhir.model.r4.Integer -> + value.value?.let { + NumberIndex(searchParam.name, searchParam.path, BigDecimal.fromInt(it)) + } + is com.google.fhir.model.r4.Decimal -> + value.value?.let { NumberIndex(searchParam.name, searchParam.path, it) } + else -> null + } + + private fun dateIndex( + searchParam: SearchParamDefinition, + value: com.google.fhir.model.r4.Date, + ): DateIndex? { + val fhirDate = value.value ?: return null + val (fromEpochDay, toEpochDay) = fhirDateToEpochDayRange(fhirDate) + return DateIndex(searchParam.name, searchParam.path, fromEpochDay, toEpochDay) + } + + private fun dateTimeIndex(searchParam: SearchParamDefinition, value: Any): DateTimeIndex? { + return when (value) { + is DateTime -> { + val fhirDateTime = value.value ?: return null + val (fromMs, toMs) = fhirDateTimeToEpochMillisRange(fhirDateTime) + DateTimeIndex(searchParam.name, searchParam.path, fromMs, toMs) + } + // No need to add precision because an instant is meant to have zero width + is com.google.fhir.model.r4.Instant -> { + val fhirDateTime = value.value ?: return null + val ms = fhirDateTimeToEpochMillis(fhirDateTime) + DateTimeIndex(searchParam.name, searchParam.path, ms, ms) + } + is Period -> { + val startMs = value.start?.value?.let { fhirDateTimeToEpochMillis(it) } ?: 0L + val endMs = value.end?.value?.let { fhirDateTimeToEndEpochMillis(it) } ?: Long.MAX_VALUE + DateTimeIndex(searchParam.name, searchParam.path, startMs, endMs) + } + is Timing -> { + if (value.event.isNotEmpty()) { + val events = value.event.mapNotNull { it.value } + if (events.isEmpty()) return null + DateTimeIndex( + searchParam.name, + searchParam.path, + events.minOf { fhirDateTimeToEpochMillis(it) }, + events.maxOf { fhirDateTimeToEndEpochMillis(it) }, + ) + } else { + null + } + } + is com.google.fhir.model.r4.String -> { + // e.g. CarePlan may have schedule as a string value + try { + val fhirDateTime = FhirDateTime.fromString(value.value) + if (fhirDateTime != null) { + val (fromMs, toMs) = fhirDateTimeToEpochMillisRange(fhirDateTime) + DateTimeIndex(searchParam.name, searchParam.path, fromMs, toMs) + } else { + null + } + } catch (_: IllegalStateException) { + null + } + } + else -> null + } + } + + /** + * Extension to express [HumanName] as a separated string using [separator]. See + * https://www.hl7.org/fhir/patient.html#search + */ + private fun HumanName.asString(separator: CharSequence = " "): kotlin.String { + return (prefix.mapNotNull { it.value } + + given.mapNotNull { it.value } + + listOfNotNull(family?.value) + + suffix.mapNotNull { it.value } + + listOfNotNull(text?.value)) + .filter { it.isNotBlank() } + .joinToString(separator) + } + + /** + * Extension to express [Address] as a string using [separator]. See + * https://www.hl7.org/fhir/patient.html#search + */ + private fun Address.asString(separator: CharSequence = ", "): kotlin.String { + return (line.mapNotNull { it.value } + + listOfNotNull( + city?.value, + district?.value, + state?.value, + country?.value, + postalCode?.value, + text?.value, + )) + .filter { it.isNotBlank() } + .joinToString(separator) + } + + private fun stringIndex(searchParam: SearchParamDefinition, value: Any): StringIndex? { + val stringValue = + when (value) { + is HumanName -> value.asString() + is Address -> value.asString() + is com.google.fhir.model.r4.String -> value.value + else -> value.toString() + } + return if (!stringValue.isNullOrEmpty()) { + StringIndex(searchParam.name, searchParam.path, stringValue) + } else { + null + } + } + + private fun tokenIndex(searchParam: SearchParamDefinition, value: Any): List = + when (value) { + is com.google.fhir.model.r4.Boolean -> + value.value?.let { + listOf( + TokenIndex(searchParam.name, searchParam.path, system = null, it.toString()), + ) + } + ?: emptyList() + is Identifier -> { + val identifierValue = value.value?.value + if (identifierValue != null) { + listOf( + TokenIndex( + searchParam.name, + searchParam.path, + value.system?.value, + identifierValue, + ), + ) + } else { + emptyList() + } + } + is CodeableConcept -> { + value.coding.mapNotNull { coding -> + val codeValue = coding.code?.value + if (codeValue != null && codeValue.isNotEmpty()) { + TokenIndex( + searchParam.name, + searchParam.path, + coding.system?.value ?: "", + codeValue, + ) + } else { + null + } + } + } + is Coding -> { + val code = value.code?.value + if (code != null) { + listOf( + TokenIndex(searchParam.name, searchParam.path, value.system?.value ?: "", code), + ) + } else { + emptyList() + } + } + is com.google.fhir.model.r4.Code -> { + val code = value.value + if (code != null) { + listOf(TokenIndex(searchParam.name, searchParam.path, null, code)) + } else { + emptyList() + } + } + is Id -> { + val idValue = value.value + if (idValue != null) { + listOf(TokenIndex(searchParam.name, searchParam.path, null, idValue)) + } else { + emptyList() + } + } + else -> emptyList() + } + + private fun referenceIndex(searchParam: SearchParamDefinition, value: Any): ReferenceIndex? { + val refValue = + when (value) { + is Reference -> value.reference?.value + is Canonical -> value.value + is Uri -> value.value + else -> null + } + return refValue?.let { ReferenceIndex(searchParam.name, searchParam.path, it) } + } + + private fun quantityIndex( + searchParam: SearchParamDefinition, + value: Any, + ): List { + return when (value) { + is Money -> { + val moneyValue = value.value?.value ?: return emptyList() + // Currencies enum name is the code with first letter capitalized (e.g., "Usd" for "USD") + val currencyCode = value.currency?.value?.name?.uppercase() ?: return emptyList() + listOf( + QuantityIndex( + searchParam.name, + searchParam.path, + FHIR_CURRENCY_CODE_SYSTEM, + currencyCode, + moneyValue, + ), + ) + } + is Quantity -> { + val quantityValue = value.value?.value ?: return emptyList() + val quantityIndices = mutableListOf() + + // Add quantity indexing record for the human readable unit + val unit = value.unit?.value + if (unit != null) { + quantityIndices.add( + QuantityIndex(searchParam.name, searchParam.path, "", unit, quantityValue), + ) + } + + // Add quantity indexing record for the coded unit + val system = value.system?.value + val code = value.code?.value + var canonicalCode = code + var canonicalValue = quantityValue + if (system == ucumUrl && code != null) { + try { + val ucumUnit = + UnitConverter.getCanonicalFormOrOriginal(UcumValue(code, quantityValue)) + canonicalCode = ucumUnit.code + canonicalValue = ucumUnit.value + } catch (_: Exception) { + // Fall through with original values + } + } + quantityIndices.add( + QuantityIndex( + searchParam.name, + searchParam.path, + system ?: "", + canonicalCode ?: "", + canonicalValue, + ), + ) + quantityIndices + } + else -> emptyList() + } + } + + private fun uriIndex(searchParam: SearchParamDefinition, value: Any): UriIndex? { + val uri = + when (value) { + is Uri -> value.value + is com.google.fhir.model.r4.String -> value.value + else -> null + } + return if (!uri.isNullOrEmpty()) { + UriIndex(searchParam.name, searchParam.path, uri) + } else { + null + } + } + + private fun specialIndex(value: Any?): PositionIndex? { + return when (value) { + is Location.Position -> { + val lat = value.latitude.value?.doubleValue(false) ?: return null + val lon = value.longitude.value?.doubleValue(false) ?: return null + PositionIndex(lat, lon) + } + else -> null + } + } + + /** + * The FHIR currency code system. See: https://bit.ly/30YB3ML. See: + * https://www.hl7.org/fhir/valueset-currencies.html. + */ + private const val FHIR_CURRENCY_CODE_SYSTEM = "urn:iso:std:iso:4217" + + fun createLastUpdatedIndex(resourceType: ResourceType, epochMillis: Long) = + DateTimeIndex( + name = LAST_UPDATED, + path = arrayOf(resourceType.name, "meta", "lastUpdated").joinToString(separator = "."), + from = epochMillis, + to = epochMillis, + ) + + fun createLocalLastUpdatedIndex(resourceType: ResourceType, epochMillis: Long) = + DateTimeIndex( + name = LOCAL_LAST_UPDATED, + path = arrayOf(resourceType.name, "meta", "localLastUpdated").joinToString(separator = "."), + from = epochMillis, + to = epochMillis, + ) + + // --- Date utility functions --- + + /** + * Converts a [FhirDate] to an epoch day range (from, to) accounting for the date's precision. + */ + private fun fhirDateToEpochDayRange(date: FhirDate): Pair = + when (date) { + is FhirDate.Date -> { + val epochDay = date.date.toEpochDays().toLong() + epochDay to epochDay + } + is FhirDate.YearMonth -> { + val firstDay = LocalDate(date.value.year, date.value.month, 1) + val nextMonth = firstDay.plus(1, DateTimeUnit.MONTH) + firstDay.toEpochDays().toLong() to (nextMonth.toEpochDays().toLong() - 1) + } + is FhirDate.Year -> { + val firstDay = LocalDate(date.value, 1, 1) + val nextYear = LocalDate(date.value + 1, 1, 1) + firstDay.toEpochDays().toLong() to (nextYear.toEpochDays().toLong() - 1) + } + } + + /** Converts a [FhirDateTime] to epoch milliseconds (start of the range). */ + private fun fhirDateTimeToEpochMillis(dateTime: FhirDateTime): Long = + when (dateTime) { + is FhirDateTime.DateTime -> { + dateTime.dateTime.toInstant(dateTime.utcOffset).toEpochMilliseconds() + } + is FhirDateTime.Date -> { + dateTime.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + } + is FhirDateTime.YearMonth -> { + val firstDay = LocalDate(dateTime.value.year, dateTime.value.month, 1) + firstDay.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + } + is FhirDateTime.Year -> { + val firstDay = LocalDate(dateTime.value, 1, 1) + firstDay.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + } + } + + /** Converts a [FhirDateTime] to epoch milliseconds (end of the range, precision-aware). */ + private fun fhirDateTimeToEndEpochMillis(dateTime: FhirDateTime): Long = + when (dateTime) { + is FhirDateTime.DateTime -> { + dateTime.dateTime.toInstant(dateTime.utcOffset).toEpochMilliseconds() + } + is FhirDateTime.Date -> { + val nextDay = dateTime.date.plus(1, DateTimeUnit.DAY) + nextDay.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() - 1 + } + is FhirDateTime.YearMonth -> { + val firstDay = LocalDate(dateTime.value.year, dateTime.value.month, 1) + val nextMonth = firstDay.plus(1, DateTimeUnit.MONTH) + nextMonth.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() - 1 + } + is FhirDateTime.Year -> { + val nextYear = LocalDate(dateTime.value + 1, 1, 1) + nextYear.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() - 1 + } + } + + /** + * Converts a [FhirDateTime] to an epoch millisecond range (from, to) accounting for precision. + */ + private fun fhirDateTimeToEpochMillisRange(dateTime: FhirDateTime): Pair = + fhirDateTimeToEpochMillis(dateTime) to fhirDateTimeToEndEpochMillis(dateTime) + } +} diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/ResourceIndices.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/ResourceIndices.kt new file mode 100644 index 0000000000..0447a197f7 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/ResourceIndices.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2025-2026 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://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.index + +import com.google.android.fhir.index.entities.DateIndex +import com.google.android.fhir.index.entities.DateTimeIndex +import com.google.android.fhir.index.entities.NumberIndex +import com.google.android.fhir.index.entities.PositionIndex +import com.google.android.fhir.index.entities.QuantityIndex +import com.google.android.fhir.index.entities.ReferenceIndex +import com.google.android.fhir.index.entities.StringIndex +import com.google.android.fhir.index.entities.TokenIndex +import com.google.android.fhir.index.entities.UriIndex +import com.google.fhir.model.r4.terminologies.ResourceType + +/** + * Indices extracted from the resource of [resourceType] and [resourceId]. Used to create index + * records in the database to support search. + * + * See https://www.hl7.org/fhir/search.html. + */ +internal data class ResourceIndices( + val resourceType: ResourceType, + val resourceId: String, + val numberIndices: List, + val dateIndices: List, + val dateTimeIndices: List, + val stringIndices: List, + val uriIndices: List, + val tokenIndices: List, + val quantityIndices: List, + val referenceIndices: List, + val positionIndices: List, +) { + class Builder(private val resourceType: ResourceType, private val resourceId: String) { + private val stringIndices = mutableListOf() + private val referenceIndices = mutableListOf() + private val tokenIndices = mutableListOf() + private val quantityIndices = mutableListOf() + private val uriIndices = mutableListOf() + private val dateIndices = mutableListOf() + private val dateTimeIndices = mutableListOf() + private val numberIndices = mutableListOf() + private val positionIndices = mutableListOf() + + constructor(indices: ResourceIndices) : this(indices.resourceType, indices.resourceId) { + stringIndices += indices.stringIndices + referenceIndices += indices.referenceIndices + tokenIndices += indices.tokenIndices + quantityIndices += indices.quantityIndices + uriIndices += indices.uriIndices + dateIndices += indices.dateIndices + dateTimeIndices += indices.dateTimeIndices + numberIndices += indices.numberIndices + positionIndices += indices.positionIndices + } + + fun addNumberIndex(numberIndex: NumberIndex) { + if (numberIndices.contains(numberIndex)) { + return + } + numberIndices.add(numberIndex) + } + + fun addDateIndex(dateIndex: DateIndex) { + if (dateIndices.contains(dateIndex)) { + return + } + dateIndices.add(dateIndex) + } + + fun addDateTimeIndex(dateTimeIndex: DateTimeIndex) { + if (dateTimeIndices.contains(dateTimeIndex)) { + return + } + dateTimeIndices.add(dateTimeIndex) + } + + fun addStringIndex(stringIndex: StringIndex) { + if (stringIndices.contains(stringIndex)) { + return + } + stringIndices.add(stringIndex) + } + + fun addUriIndex(uriIndex: UriIndex) { + if (uriIndices.contains(uriIndex)) { + return + } + uriIndices.add(uriIndex) + } + + fun addTokenIndex(tokenIndex: TokenIndex) { + if (tokenIndices.contains(tokenIndex)) { + return + } + tokenIndices.add(tokenIndex) + } + + fun addQuantityIndex(quantityIndex: QuantityIndex) { + if (quantityIndices.contains(quantityIndex)) { + return + } + quantityIndices.add(quantityIndex) + } + + fun addReferenceIndex(referenceIndex: ReferenceIndex) { + if (referenceIndices.contains(referenceIndex)) { + return + } + referenceIndices.add(referenceIndex) + } + + fun addPositionIndex(positionIndex: PositionIndex) { + if (positionIndices.contains(positionIndex)) { + return + } + positionIndices.add(positionIndex) + } + + fun build() = + ResourceIndices( + resourceType = resourceType, + resourceId = resourceId, + numberIndices = numberIndices.toList(), + dateIndices = dateIndices.toList(), + dateTimeIndices = dateTimeIndices.toList(), + stringIndices = stringIndices.toList(), + uriIndices = uriIndices.toList(), + tokenIndices = tokenIndices.toList(), + quantityIndices = quantityIndices.toList(), + referenceIndices = referenceIndices.toList(), + positionIndices = positionIndices.toList(), + ) + } +} diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/SearchParamDefinition.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/SearchParamDefinition.kt new file mode 100644 index 0000000000..7999ac3004 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/SearchParamDefinition.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2025-2026 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://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.index + +/** Definition of a search parameter for indexing and querying FHIR resources. */ +data class SearchParamDefinition( + val name: String, + val type: SearchParamType, + val path: String, +) + +/** The type of a search parameter. */ +enum class SearchParamType { + NUMBER, + DATE, + STRING, + TOKEN, + REFERENCE, + COMPOSITE, + QUANTITY, + URI, + SPECIAL, +} diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/SearchParamDefinitionsProvider.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/SearchParamDefinitionsProvider.kt new file mode 100644 index 0000000000..5d261e4212 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/SearchParamDefinitionsProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2025-2026 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://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.index + +import com.google.fhir.model.r4.Resource + +/** Provides a list of [SearchParamDefinition]s for a [Resource]. */ +internal fun interface SearchParamDefinitionsProvider { + + /** @return [SearchParamDefinition]s based on the resource type name. */ + fun get(resource: Resource): List +} + +/** + * An implementation of [SearchParamDefinitionsProvider] that provides the [List]< + * [SearchParamDefinition]> from the default params and custom params(if any). + */ +internal class SearchParamDefinitionsProviderImpl( + private val defaultParams: Map> = emptyMap(), + private val customParams: Map> = emptyMap(), +) : SearchParamDefinitionsProvider { + + override fun get(resource: Resource): List { + val resourceTypeName = resource::class.simpleName ?: return emptyList() + return defaultParams.getOrElse(resourceTypeName) { emptyList() } + + customParams.getOrElse(resourceTypeName) { emptyList() } + } +} diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/DateIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/DateIndex.kt new file mode 100644 index 0000000000..71472469dc --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/DateIndex.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2025-2026 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://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.index.entities + +/** + * An index record for a `Date` value in a FHIR resource. + * + * Note one fundamental difference between `Date` and `DateTime` data types in FHIR in that `Date` + * does not contain timezone info where `DateTime` does. + * + * See https://hl7.org/FHIR/search.html#date. + */ +internal data class DateIndex( + /** The name of the date index, e.g. "birthdate". */ + val name: String, + /** The path of the date index, e.g. "Patient.birthdate". */ + val path: String, + /** The epoch day of the first date. */ + val from: Long, + /** The epoch day of the last date. */ + val to: Long, +) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/DateTimeIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/DateTimeIndex.kt new file mode 100644 index 0000000000..0611ca8ea6 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/DateTimeIndex.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2025-2026 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://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.index.entities + +/** + * An index record for a `DateTime` value in a FHIR resource. + * + * This could be used to index resources by `DateTime`, `Instant`, `Period` and `Timing` data types. + * + * Note one fundamental difference between `DateTime` and `Date` data types in FHIR in that + * `DateTime` contains timezone info where `Date` does not. + * + * See https://hl7.org/FHIR/search.html#date. + */ +internal data class DateTimeIndex( + /** The name of the date index, e.g. "birthdate". */ + val name: String, + /** The path of the date index, e.g. "Patient.birthdate". */ + val path: String, + /** The epoch time of the first millisecond. */ + val from: Long, + /** The epoch time of the last millisecond. */ + val to: Long, +) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/NumberIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/NumberIndex.kt new file mode 100644 index 0000000000..6a81bb172d --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/NumberIndex.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2025-2026 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://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.index.entities + +import com.ionspin.kotlin.bignum.decimal.BigDecimal + +/** + * An index record for a number value in a resource. + * + * See https://hl7.org/FHIR/search.html#number. + */ +internal data class NumberIndex( + /** The name of the number index, e.g. "probability". */ + val name: String, + /** The path of the number index, e.g. "RiskAssessment.prediction.probability". */ + val path: String, + /** The value of the number index, e.g. "0.1". */ + val value: BigDecimal, +) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/PositionIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/PositionIndex.kt new file mode 100644 index 0000000000..96c5fca8f1 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/PositionIndex.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025-2026 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://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.index.entities + +/** + * An index record for a position value in a location resource. + * + * See https://www.hl7.org/fhir/search.html#special. + */ +internal data class PositionIndex(val latitude: Double, val longitude: Double) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/QuantityIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/QuantityIndex.kt new file mode 100644 index 0000000000..f7ad8f5e8b --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/QuantityIndex.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025-2026 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://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.index.entities + +import com.ionspin.kotlin.bignum.decimal.BigDecimal + +/** + * An index record for a quantity value in a resource. + * + * See https://hl7.org/FHIR/search.html#quantity. + */ +internal data class QuantityIndex( + val name: String, + val path: String, + val system: String, + val code: String, + val value: BigDecimal, +) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/ReferenceIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/ReferenceIndex.kt new file mode 100644 index 0000000000..d3378fd757 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/ReferenceIndex.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2025-2026 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://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.index.entities + +/** + * An index record for a reference value in a resource. + * + * See https://hl7.org/FHIR/search.html#reference. + */ +internal data class ReferenceIndex( + /** The name of the reference index, e.g. "subject". */ + val name: String, + /** The path of the reference index, e.g. "Observation.subject". */ + val path: String, + /** The value of the reference index, e.g. "Patient/123". */ + val value: String, +) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/StringIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/StringIndex.kt new file mode 100644 index 0000000000..66c73d59d0 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/StringIndex.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2025-2026 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://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.index.entities + +/** + * An index record for a string value in a resource. + * + * See https://hl7.org/FHIR/search.html#string. + */ +internal data class StringIndex( + /** The name of the string index, e.g. "given". */ + val name: String, + /** The path of the string index, e.g. "Patient.name.given". */ + val path: String, + /** The value of the string index, e.g. "Tom". */ + val value: String, +) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/TokenIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/TokenIndex.kt new file mode 100644 index 0000000000..3a626f1cdb --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/TokenIndex.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2025-2026 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://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.index.entities + +/** + * An index record for a token value in a resource. + * + * See https://hl7.org/FHIR/search.html#token. + */ +internal data class TokenIndex( + /** The name of the code index, e.g. "code". */ + val name: String, + /** The path of the code index, e.g. "Observation.code". */ + val path: String, + /** The system of the code index, e.g. "http://openmrs.org/concepts". */ + val system: String?, + /** The value of the code index, e.g. "1427AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA". */ + val value: String, +) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/UriIndex.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/UriIndex.kt new file mode 100644 index 0000000000..f6542b2444 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/index/entities/UriIndex.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025-2026 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://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.index.entities + +/** + * An index record for a URI value in a resource. + * + * See https://hl7.org/FHIR/search.html#uri. + */ +internal data class UriIndex(val name: String, val path: String, val value: String) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/Search.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/Search.kt new file mode 100644 index 0000000000..7bff7cdd41 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/Search.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2025-2026 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://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.search + +import com.google.fhir.model.r4.terminologies.ResourceType + +// TODO: Phase 3 — Full search DSL implementation +/** Specifies search criteria for querying the FHIR database. */ +class Search(val type: ResourceType) + +internal const val LOCAL_LAST_UPDATED = "local_lastUpdated" +internal const val LAST_UPDATED = "_lastUpdated" diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/SearchQuery.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/SearchQuery.kt new file mode 100644 index 0000000000..5e79435061 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/search/SearchQuery.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025-2026 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://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.search + +// TODO: Phase 3 — Full search query implementation +/** Represents a compiled search query ready for database execution. */ +data class SearchQuery( + val query: String, + val args: List, +) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/ConflictResolver.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/ConflictResolver.kt new file mode 100644 index 0000000000..ceade55136 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/ConflictResolver.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2025-2026 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://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.sync + +import com.google.fhir.model.r4.Resource + +// TODO: Phase 6 — Full conflict resolver implementation + +/** Resolves conflicts between local and remote FHIR resources during synchronization. */ +fun interface ConflictResolver { + fun resolve(local: Resource, remote: Resource): ConflictResolutionResult +} + +/** The result of resolving a conflict between local and remote resources. */ +sealed class ConflictResolutionResult { + object AcceptLocal : ConflictResolutionResult() + + object AcceptRemote : ConflictResolutionResult() +} + +/** A [ConflictResolver] that always accepts the local version of the resource. */ +object AcceptLocalConflictResolver : ConflictResolver { + override fun resolve(local: Resource, remote: Resource) = ConflictResolutionResult.AcceptLocal +} + +/** A [ConflictResolver] that always accepts the remote version of the resource. */ +object AcceptRemoteConflictResolver : ConflictResolver { + override fun resolve(local: Resource, remote: Resource) = ConflictResolutionResult.AcceptRemote +} diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/HttpAuthenticator.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/HttpAuthenticator.kt new file mode 100644 index 0000000000..0f637cc134 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/HttpAuthenticator.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2025-2026 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://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.sync + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** Provides HTTP authentication credentials for FHIR server communication. */ +fun interface HttpAuthenticator { + fun getAuthenticationMethod(): HttpAuthenticationMethod +} + +/** Represents an HTTP authentication method with its authorization header value. */ +sealed interface HttpAuthenticationMethod { + fun getAuthorizationHeader(): String + + data class Basic(val username: String, val password: String) : HttpAuthenticationMethod { + @OptIn(ExperimentalEncodingApi::class) + override fun getAuthorizationHeader(): String { + val credentials = "$username:$password" + return "Basic ${Base64.encode(credentials.encodeToByteArray())}" + } + } + + data class Bearer(val token: String) : HttpAuthenticationMethod { + override fun getAuthorizationHeader(): String = "Bearer $token" + } +} diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/remote/HttpLogger.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/remote/HttpLogger.kt new file mode 100644 index 0000000000..b5044b76a1 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/remote/HttpLogger.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2025-2026 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://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.sync.remote + +/** + * Configuration for logging HTTP communication between the engine and the remote FHIR server. + * + * @property level The level of detail to log. + * @property headersToIgnore A set of header names to exclude from logged output. + */ +data class HttpLogger( + val level: Level = Level.NONE, + val headersToIgnore: Set = emptySet(), +) { + enum class Level { + /** No logs. */ + NONE, + + /** Logs request and response lines. */ + BASIC, + + /** Logs request and response lines and their respective headers. */ + HEADERS, + + /** Logs request and response lines, headers, and bodies. */ + BODY, + } + + companion object { + val NONE = HttpLogger() + } +} diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/upload/SyncUploadProgress.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/upload/SyncUploadProgress.kt new file mode 100644 index 0000000000..24e4804c1b --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/upload/SyncUploadProgress.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025-2026 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://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.sync.upload + +// TODO: Phase 6 — Full sync upload implementation + +/** Tracks the progress of a sync upload operation. */ +data class SyncUploadProgress( + val remaining: Int, + val initialTotal: Int, + val uploadError: ResourceSyncException? = null, +) + +/** Exception wrapping a resource sync failure. */ +data class ResourceSyncException( + val resourceType: String, + val exception: Exception, +) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/upload/UploadRequestResult.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/upload/UploadRequestResult.kt new file mode 100644 index 0000000000..6ccc69a0f4 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/upload/UploadRequestResult.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025-2026 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://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.sync.upload + +import com.google.android.fhir.LocalChange +import com.google.fhir.model.r4.Resource + +// TODO: Phase 6 — Full upload request result implementation + +/** The result of an upload request to the FHIR server. */ +sealed class UploadRequestResult { + data class Success( + val successfulUploadResponseMappings: List, + ) : UploadRequestResult() + + data class Failure( + val localChanges: List, + val uploadError: ResourceSyncException, + ) : UploadRequestResult() +} + +/** Maps a local change to the server's response resource after a successful upload. */ +data class SuccessfulUploadResponseMapping( + val localChange: LocalChange, + val output: Resource, +) diff --git a/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/upload/UploadStrategy.kt b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/upload/UploadStrategy.kt new file mode 100644 index 0000000000..e2876dc042 --- /dev/null +++ b/engine-kmp/src/commonMain/kotlin/com/google/android/fhir/sync/upload/UploadStrategy.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2025-2026 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://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.sync.upload + +// TODO: Phase 6 — Full upload strategy implementation + +/** Defines the strategy for uploading FHIR resources to a server. */ +class UploadStrategy internal constructor() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b9586c4937..ee7761a51e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -57,6 +57,7 @@ kotlin = "2.2.20" kotlin-fhir = "1.0.0-beta02" kotlinpoet = "2.2.0" kotlinx-coroutines = "1.10.2" +kotlinx-datetime = "0.6.2" kotlinx-serialization-json = "1.9.0" kotlinx-coroutines-swing = "1.10.2" kotlinx-io-core = "0.8.0" @@ -168,6 +169,7 @@ kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-coroutines-playservices = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines-swing" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 9031c4033f..a0fddb593a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -76,3 +76,5 @@ include(":workflow_demo") include(":datacapture-kmp") include(":sdc-kmp-demo") + +include(":engine-kmp")