Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support memoization #219

Merged
merged 27 commits into from
Feb 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
15de0ee
A provisional API to implement memoizing of regular and suspending fu…
nedtwigg Feb 9, 2024
5884367
Merge branch 'main' into feat/memo
nedtwigg Feb 13, 2024
5483878
Create the `suspend fun memoize` functions.
nedtwigg Feb 13, 2024
4b3da7e
Add compileOnly dependency for `selfie-lib` on `kotlinx-serialization…
nedtwigg Feb 13, 2024
8dbeb11
Try adding `kotlinx-serialization-json` to the test implementation to…
nedtwigg Feb 13, 2024
756d8ad
Add `memoizeAsJson` to support any Kotlin `@Serializable` type.
nedtwigg Feb 13, 2024
886f283
Minor refactoring.
nedtwigg Feb 13, 2024
e80ace9
Add binary I/O into our filesystem abstraction.
nedtwigg Feb 13, 2024
197741e
Better formatting for dense inline functions.
nedtwigg Feb 13, 2024
7e2649b
Better implementation of `toBe` and `toBe_TODO`.
nedtwigg Feb 13, 2024
8d4c4ff
Minor improvement.
nedtwigg Feb 13, 2024
b727fd9
Add binary API.
nedtwigg Feb 14, 2024
aac2426
Add `UT_Memoize`.
nedtwigg Feb 14, 2024
68efa25
Add a `memoizeBinarySerializable` method for `java.io.Serializable`.
nedtwigg Feb 14, 2024
e60eb98
Add `nanoTime`.
nedtwigg Feb 14, 2024
c5b3d7f
Fix commented-out enabled.
nedtwigg Feb 14, 2024
053da13
Make the `nanoTimeTest` unique.
nedtwigg Feb 14, 2024
dd72619
Add a test for the memoization (string and binary).
nedtwigg Feb 14, 2024
7b79a2e
Add the coroutine memo stuff into `object Selfie`.
nedtwigg Feb 14, 2024
6fb35b7
Remove all the coroutine stuff with find-replace more-or-less.
nedtwigg Feb 14, 2024
aa1bb1c
Oops, committed a changed UT_Memoize.
nedtwigg Feb 14, 2024
bb75cf0
Fix `toBeFile_TODO`.
nedtwigg Feb 14, 2024
4fa987b
Make Selfie's non-suspend memo methods `@JvmStatic`.
nedtwigg Feb 14, 2024
2a4e443
Put all the `Roundtrip` stuff into one file.
nedtwigg Feb 14, 2024
bf49d4c
Move the `non-suspend` memos into their own files.
nedtwigg Feb 14, 2024
0f60c30
Put the rest of the memo classes into their own files.
nedtwigg Feb 14, 2024
5c9e578
Update changelog.
nedtwigg Feb 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions jvm/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Added
- **Memoization** ([#219](https://github.com/diffplug/selfie/pull/219) implements [#215](https://github.com/diffplug/selfie/issues/215))
- like `expectSelfie`, all are available as `Selfie.memoize` or as `suspend fun` in `com.diffplug.selfie.coroutines`.
```kotlin
val cachedResult: ByteArray = Selfie.memoizeBinary { dalleJpeg() }.toBePath("example.jpg")
val cachedResult: String = Selfie.memoize { someString() }.toBe("what it was earlier")
val cachedResult: T = Selfie.memoizeAsJson { anyKotlinxSerializable() }.toBe("""{"key": "value"}""")
val cachedResult: T = Selfie.memoizeBinarySerializable { anyJavaIoSerializable() }.toMatchDisk()
```

## [1.2.0] - 2024-02-12
### Added
Expand Down
4 changes: 2 additions & 2 deletions jvm/gradle/spotless.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ spotless {
target 'src/**/*.kt'
licenseHeaderFile 干.file("spotless/license-${license}.java")
ktfmt()
for (modifier in ['', 'override ', 'public ', 'protected ', 'private ', 'internal ', 'infix ', 'expected ', 'actual ', 'suspend ']) {
for (key in ['inline', 'fun', 'abstract fun', 'val', 'override']) {
for (modifier in ['', 'override ', 'public ', 'protected ', 'private ', 'internal ', 'infix ', 'expected ', 'actual ']) {
for (key in ['inline', 'fun', 'abstract fun', 'suspend fun', 'val', 'override']) {
String toCheck = "$modifier$key"
replaceRegex("dense $toCheck", "\n\n(\\s*)$toCheck ", "\n\$1$toCheck ")
}
Expand Down
7 changes: 6 additions & 1 deletion jvm/selfie-lib/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,16 @@ kotlin {
nodejs()
}
sourceSets {
commonMain {}
commonMain {
dependencies {
compileOnly("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
}
}
commonTest {
dependencies {
implementation kotlin('test')
implementation("io.kotest:kotest-assertions-core:$ver_KOTEST")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* Copyright (C) 2024 DiffPlug
*
* 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.diffplug.selfie

import com.diffplug.selfie.guts.DiskSnapshotTodo
import com.diffplug.selfie.guts.DiskStorage
import com.diffplug.selfie.guts.ToBeFileTodo
import com.diffplug.selfie.guts.recordCall

class MemoBinary<T>(
private val disk: DiskStorage,
private val roundtrip: Roundtrip<T, ByteArray>,
private val generator: () -> T
) {
fun toMatchDisk(sub: String = ""): T {
return toMatchDiskImpl(sub, false)
}
fun toMatchDisk_TODO(sub: String = ""): T {
return toMatchDiskImpl(sub, true)
}
private fun toMatchDiskImpl(sub: String, isTodo: Boolean): T {
val call = recordCall(false)
if (Selfie.system.mode.canWrite(isTodo, call, Selfie.system)) {
val actual = generator()
disk.writeDisk(Snapshot.of(roundtrip.serialize(actual)), sub, call)
if (isTodo) {
Selfie.system.writeInline(DiskSnapshotTodo.createLiteral(), call)
}
return actual
} else {
if (isTodo) {
throw Selfie.system.fs.assertFailed(
"Can't call `toMatchDisk_TODO` in ${Mode.readonly} mode!")
} else {
val snapshot =
disk.readDisk(sub, call)
?: throw Selfie.system.fs.assertFailed(Selfie.system.mode.msgSnapshotNotFound())
if (!snapshot.subject.isBinary || snapshot.facets.isNotEmpty()) {
throw Selfie.system.fs.assertFailed(
"Expected a binary subject with no facets, got ${snapshot}")
}
return roundtrip.parse(snapshot.subject.valueBinary())
}
}
}
private fun resolvePath(subpath: String) = Selfie.system.layout.rootFolder.resolveFile(subpath)
fun toBeFile_TODO(subpath: String): T {
return toBeFileImpl(subpath, true)
}
fun toBeFile(subpath: String): T {
return toBeFileImpl(subpath, false)
}
private fun toBeFileImpl(subpath: String, isTodo: Boolean): T {
val call = recordCall(false)
val writable = Selfie.system.mode.canWrite(isTodo, call, Selfie.system)
if (writable) {
val actual = generator()
if (isTodo) {
Selfie.system.writeInline(ToBeFileTodo.createLiteral(), call)
}
Selfie.system.fs.fileWriteBinary(resolvePath(subpath), roundtrip.serialize(actual))
return actual
} else {
if (isTodo) {
throw Selfie.system.fs.assertFailed("Can't call `toBeFile_TODO` in ${Mode.readonly} mode!")
} else {
return roundtrip.parse(Selfie.system.fs.fileReadBinary(resolvePath(subpath)))
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright (C) 2024 DiffPlug
*
* 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.diffplug.selfie

import com.diffplug.selfie.guts.DiskSnapshotTodo
import com.diffplug.selfie.guts.DiskStorage
import com.diffplug.selfie.guts.LiteralString
import com.diffplug.selfie.guts.LiteralValue
import com.diffplug.selfie.guts.recordCall

class MemoString<T>(
private val disk: DiskStorage,
private val roundtrip: Roundtrip<T, String>,
private val generator: () -> T
) {
fun toMatchDisk(sub: String = ""): T {
return toMatchDiskImpl(sub, false)
}
fun toMatchDisk_TODO(sub: String = ""): T {
return toMatchDiskImpl(sub, true)
}
private fun toMatchDiskImpl(sub: String, isTodo: Boolean): T {
val call = recordCall(false)
if (Selfie.system.mode.canWrite(isTodo, call, Selfie.system)) {
val actual = generator()
disk.writeDisk(Snapshot.of(roundtrip.serialize(actual)), sub, call)
if (isTodo) {
Selfie.system.writeInline(DiskSnapshotTodo.createLiteral(), call)
}
return actual
} else {
if (isTodo) {
throw Selfie.system.fs.assertFailed(
"Can't call `toMatchDisk_TODO` in ${Mode.readonly} mode!")
} else {
val snapshot =
disk.readDisk(sub, call)
?: throw Selfie.system.fs.assertFailed(Selfie.system.mode.msgSnapshotNotFound())
if (snapshot.subject.isBinary || snapshot.facets.isNotEmpty()) {
throw Selfie.system.fs.assertFailed(
"Expected a string subject with no facets, got ${snapshot}")
}
return roundtrip.parse(snapshot.subject.valueString())
}
}
}
fun toBe_TODO(unusedArg: Any? = null): T {
return toBeImpl(null)
}
fun toBe(expected: String): T {
return toBeImpl(expected)
}
private fun toBeImpl(snapshot: String?): T {
val call = recordCall(false)
val writable = Selfie.system.mode.canWrite(snapshot == null, call, Selfie.system)
if (writable) {
val actual = generator()
Selfie.system.writeInline(
LiteralValue(snapshot, roundtrip.serialize(actual), LiteralString), call)
return actual
} else {
if (snapshot == null) {
throw Selfie.system.fs.assertFailed("Can't call `toBe_TODO` in ${Mode.readonly} mode!")
} else {
return roundtrip.parse(snapshot)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright (C) 2024 DiffPlug
*
* 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.diffplug.selfie

interface Roundtrip<T, SerializedForm> {
fun serialize(value: T): SerializedForm
fun parse(serialized: SerializedForm): T

companion object {
fun <T : Any> identity(): Roundtrip<T, T> = IDENTITY as Roundtrip<T, T>
private val IDENTITY =
object : Roundtrip<Any, Any> {
override fun serialize(value: Any): Any = value
override fun parse(serialized: Any): Any = serialized
}
}
}

/** Roundtrips the given type into pretty-printed Json. */
class RoundtripJson<T>(private val strategy: kotlinx.serialization.KSerializer<T>) :
Roundtrip<T, String> {
private val json =
kotlinx.serialization.json.Json {
prettyPrint = true
prettyPrintIndent = " "
}
override fun serialize(value: T): String = json.encodeToString(strategy, value)
override fun parse(serialized: String): T = json.decodeFromString(strategy, serialized)

companion object {
inline fun <reified T> of() = RoundtripJson<T>(kotlinx.serialization.serializer())
}
}
19 changes: 19 additions & 0 deletions jvm/selfie-lib/src/commonMain/kotlin/com/diffplug/selfie/Selfie.kt
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,25 @@ object Selfie {
writer.toString()
}
}

@JvmStatic fun memoize(toMemoize: () -> String) = memoize(Roundtrip.identity(), toMemoize)

@JvmStatic
fun <T> memoize(roundtrip: Roundtrip<T, String>, toMemoize: () -> T) =
MemoString(deferredDiskStorage, roundtrip, toMemoize)
/**
* Memoizes any type which is marked with `@kotlinx.serialization.Serializable` as pretty-printed
* json.
*/
inline fun <reified T> memoizeAsJson(noinline toMemoize: () -> T) =
memoize(RoundtripJson.of<T>(), toMemoize)

@JvmStatic
fun memoizeBinary(toMemoize: () -> ByteArray) = memoizeBinary(Roundtrip.identity(), toMemoize)

@JvmStatic
fun <T> memoizeBinary(roundtrip: Roundtrip<T, ByteArray>, toMemoize: () -> T) =
MemoBinary<T>(deferredDiskStorage, roundtrip, toMemoize)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
package com.diffplug.selfie.coroutines

import com.diffplug.selfie.Camera
import com.diffplug.selfie.Roundtrip
import com.diffplug.selfie.RoundtripJson
import com.diffplug.selfie.Selfie
import com.diffplug.selfie.Snapshot
import com.diffplug.selfie.guts.CoroutineDiskStorage
Expand Down Expand Up @@ -53,3 +55,16 @@ suspend fun preserveSelfiesOnDisk(vararg subsToKeep: String) {
subsToKeep.forEach { disk.keep(it) }
}
}
suspend fun memoize(toMemoize: suspend () -> String) = memoize(Roundtrip.identity(), toMemoize)
suspend fun <T> memoize(roundtrip: Roundtrip<T, String>, toMemoize: suspend () -> T) =
MemoStringSuspend(disk(), roundtrip, toMemoize)
/**
* Memoizes any type which is marked with `@kotlinx.serialization.Serializable` as pretty-printed
* json.
*/
suspend inline fun <reified T> memoizeAsJson(noinline toMemoize: suspend () -> T) =
memoize(RoundtripJson.of<T>(), toMemoize)
suspend fun memoizeBinary(toMemoize: suspend () -> ByteArray) =
memoizeBinary(Roundtrip.identity(), toMemoize)
suspend fun <T> memoizeBinary(roundtrip: Roundtrip<T, ByteArray>, toMemoize: suspend () -> T) =
MemoBinarySuspend(disk(), roundtrip, toMemoize)
Loading