Giter VIP home page Giter VIP logo

tempest's Introduction

Tempest

Typesafe DynamoDB for Kotlin and Java.

See the project website for documentation and APIs.

Efficient DynamoDB

DynamoDB applications perform best (and cost the least to operate!) when data is organized for locality:

  • Multiple types per table: The application can store different entity types in a single table. DynamoDB schemas are flexible.
  • Related entities are stored together: Entities that are accessed together should be stored together. This makes it possible to answer common queries in as few requests as possible, ideally one.

Example

Let's build a music library with the following features:

  • Fetching multiple albums, each of which contains multiple tracks.
  • Fetching individual tracks.

We express it like this in code:

Kotlin

interface MusicLibrary {
  fun getAlbum(key: AlbumKey): Album
  fun getTrack(key: TrackKey): Track
}

data class Album(
  val album_title: String,
  val album_artist: String,
  val release_date: String,
  val genre: String,
  val tracks: List<Track>
)

data class Track(
  val track_title: String,
  val run_length: String
)

Java

public interface MusicLibrary {
  Album getAlbum(AlbumKey key);
  Track getTrack(TrackKey key); 
}

public class Album {
  public final String album_title;
  public final String album_artist;
  public final String release_date;
  public final String genre;
  public final List<Track> tracks; 
}

public class Track(
  public final String track_title;
  public final String run_length;
)

We optimize for this access pattern by putting albums and tracks in the same table:

Primary Key Attributes
partition_key sort_key
ALBUM_1 INFO album_title album_artist release_date genre
The Dark Side of the Moon Pink Floyd 1973-03-01 Progressive rock
ALBUM_1 TRACK_1 track_title run_length    
Speak to Me PT1M13S    
ALBUM_1 TRACK_2 track_title run_length    
Breathe PT2M43S    
ALBUM_1 TRACK_3 track_title run_length    
On the Run PT3M36S    
...
ALBUM_2 INFO album_title album_artist release_date genre
The Wall Pink Floyd 1979-11-30 Progressive rock
ALBUM_2 TRACK_1 track_title run_length    
In the Flesh? PT3M20S    
...

This table uses a composite primary key, (parition_key, sort_key), to identify each item.

  • The key ("ALBUM_1", "INFO") identifies ALBUM_1's metadata.
  • The key ("ALBUM_1", "TRACK_1") identifies ALBUM_1's first track.

This table stores tracks belonging to the same album together and sorts them by the track number. The application needs only one request to DynamoDB to get the album and its tracks.

aws dynamodb query \
    --table-name music_library_items \
    --key-conditions '{ 
        "PK": { 
            "ComparisonOperator": "EQ",
            "AttributeValueList": [ { "S": "ALBUM_1" } ]
        } 
    }'

Why Tempest?

For locality, we smashed together several entity types in the same table. This improves performance! But it breaks type safety in DynamoDBMapper.

DynamoDBMapper API

DynamoDBMapper / DynamoDbEnhancedClient, the official Java API, forces you to write weakly-typed code that models the actual persistence type.

Kotlin

// NOTE: This is not Tempest! It is an example used for comparison.
@DynamoDBTable(tableName = "music_library_items")
class MusicLibraryItem {
  // All Items.
  @DynamoDBHashKey
  var partition_key: String? = null
  @DynamoDBRangeKey
  var sort_key: String? = null

  // AlbumInfo.
  @DynamoDBAttribute
  var album_title: String? = null
  @DynamoDBAttribute
  var album_artist: String? = null
  @DynamoDBAttribute
  var release_date: String? = null
  @DynamoDBAttribute
  var genre: String? = null

  // AlbumTrack.
  @DynamoDBAttribute
  var track_title: String? = null
  @DynamoDBAttribute
  var run_length: String? = null
}

Java

// NOTE: This is not Tempest! It is an example used for comparison.
@DynamoDBTable(tableName = "music_library_items")
public class MusicLibraryItem {
  // All Items.
  String partition_key = null;
  String sort_key = null;

  // AlbumInfo.
  String album_title = null;
  String artist_name = null;
  String release_date = null;
  String genre_name = null;

  // AlbumTrack.
  String track_title = null;
  String run_length = null;

  @DynamoDBHashKey(attributeName = "partition_key")
  public String getPartitionKey() {
    return partition_key;
  }

  public void setPartitionKey(String partition_key) {
    this.partition_key = partition_key;
  }

  @DynamoDBRangeKey(attributeName = "sort_key")
  public String getSortKey() {
    return sort_key;
  }

  public void setSortKey(String sort_key) {
    this.sort_key = sort_key;
  }

  @DynamoDBAttribute(attributeName = "album_title")
  public String getAlbumTitle() {
    return album_title;
  }

  public void setAlbumTitle(String album_title) {
    this.album_title = album_title;
  }

  @DynamoDBAttribute(attributeName = "artist_name")
  public String getArtistName() {
    return artist_name;
  }

  public void setArtistName(String artist_name) {
    this.artist_name = artist_name;
  }

  @DynamoDBAttribute(attributeName = "release_date")
  public String getReleaseDate() {
    return release_date;
  }

  public void setReleaseDate(String release_date) {
    this.release_date = release_date;
  }

  @DynamoDBAttribute(attributeName = "genre_name")
  public String getGenreName() {
    return genre_name;
  }

  public void setGenreName(String genre_name) {
    this.genre_name = genre_name;
  }

  @DynamoDBAttribute(attributeName = "track_title")
  public String getTrackTitle() {
    return track_title;
  }

  public void setTrackTitle(String track_title) {
    this.track_title = track_title;
  }

  @DynamoDBAttribute(attributeName = "run_length")
  public String getRunLength() {
    return run_length;
  }

  public void setRunLength(String run_length) {
    this.run_length = run_length;
  }
}

Note that MusicLibraryItem is a union type of all the entity types: AlbumInfo and AlbumTrack. Because all of its attributes are nullable and mutable, code that interacts with it is brittle and error prone.

Tempest API

Tempest restores maintainability without losing locality. It lets you declare strongly-typed key and item classes for each logical type in the domain layer.

Kotlin

data class AlbumInfo(
  @Attribute(name = "partition_key")
  val album_token: String,
  val album_title: String,
  val album_artist: String,
  val release_date: String,
  val genre_name: String
) {
  @Attribute(prefix = "INFO_")
  val sort_key: String = ""

  data class Key(
    val album_token: String
  ) {
    val sort_key: String = ""
  }
}

data class AlbumTrack(
  @Attribute(name = "partition_key")
  val album_token: String,
  @Attribute(name = "sort_key", prefix = "TRACK_")
  val track_token: String,
  val track_title: String,
  val run_length: String
) {
  data class Key(
    val album_token: String,
    val track_token: String
  )
}

Java

public class AlbumInfo {
  @Attribute(name = "partition_key")
  public final String album_token;
  public final String album_title;
  public final String artist_name;
  public final String release_date;
  public final String genre_name;

  @Attribute(prefix = "INFO_")
  public final String sort_key = "";

  public static class Key {
    public final String album_token;
    public final String sort_key = "";
  }
}

public class AlbumTrack {
  @Attribute(name = "partition_key")
  public final String album_token;
  @Attribute(name = "sort_key", prefix = "TRACK_")
  public final String track_token;
  public final String track_title;
  public final String run_length;

  public static class Key {
    public final String album_token;
    public final String track_token;
  }
}

You build business logic with logical types. Tempest handles mapping them to the underlying persistence type.

Note: The base item type MusicLibraryItem is still used for the LogicalTable. This type is intended to model an empty row, so all its fields should be nullable with a null default value. Using non-nullable types or fields with default values will cause issues during serialization and querying.

Kotlin

interface MusicLibraryTable : LogicalTable<MusicLibraryItem> {
  val albumInfo: InlineView<AlbumInfo.Key, AlbumInfo>
  val albumTracks: InlineView<AlbumTrack.Key, AlbumTrack>
}

private val musicLibrary: MusicLibraryTable

// Load.
fun getAlbumTitle(albumToken: String): String? {
  val key = AlbumInfo.Key(albumToken)
  val albumInfo = musicLibrary.albumInfo.load(key) ?: return null
  return albumInfo.album_title
}

// Update.
fun addAlbumTrack(
  albumToken: String, 
  track_token: String, 
  track_title: String, 
  run_length: String
) {
  val newAlbumTrack = AlbumTrack(albumToken, track_token, track_title, run_length)
  musicLibrary.albumTracks.save(newAlbumTrack)
} 

// Query.
fun getAlbumTrackTitles(albumToken: String): List<String> {
  val page = musicLibrary.albumTracks.query(
    keyCondition = BeginsWith(AlbumTrack.Key(albumToken))
  )
  return page.contents.map { it.track_title }
}

Java

public interface MusicLibraryTable extends LogicalTable<MusicLibraryItem> {
  InlineView<AlbumInfo.Key, AlbumInfo> albumInfo();
  InlineView<AlbumTrack.Key, AlbumTrack> albumTracks();
}

private MusicLibraryTable musicLibrary; 

// Load.
@Nullable
public String getAlbumTitle(String albumToken) {
  AlbumInfo albumInfo = table.albumInfo().load(new AlbumInfo.Key(albumToken));
  if (albumInfo == null) {
    return null;
  }
  return albumInfo.album_title;
}

// Update.
public void addAlbumTrack(
  String albumToken, 
  String track_token, 
  String track_title, 
  String run_length
) {
  AlbumTrack newAlbumTrack = new AlbumTrack(albumToken, track_token, track_title, run_length);
  musicLibrary.albumTracks().save(newAlbumTrack);
}

// Query.
public List<String> getAlbumTrackTitles(String albumToken) {
  Page<AlbumTrack.Key, AlbumTrack> page = musicLibrary.albumTracks().query(
      // keyCondition.
      new BeginsWith<>(
          // prefix.
          new AlbumTrack.Key(albumToken)
      )
  );
  return page.getContents().stream().map(track -> track.track_title).collect(Collectors.toList());
}

Get Tempest

For AWS SDK 1.x:

implementation "app.cash.tempest:tempest:1.10.0"

For AWS SDK 2.x:

implementation "app.cash.tempest:tempest2:1.10.0"

Migrating From Tempest 1 to Tempest 2

Please follow the Migration Guide that has been set up to upgrade from Tempest 1 (AWS SDK 1.x) to Tempest 2 (AWS SDK 2.x)

License

Copyright 2020 Square, Inc.

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.

tempest's People

Contributors

abmargb avatar adrw avatar amitgoenka avatar cconroy avatar chris-ryan-square avatar ctaslimsq avatar danieloh0714 avatar dependabot[bot] avatar frojasg avatar jaischeema avatar jaredwindover avatar kyeotic avatar lkerford avatar mhickman avatar mmollaverdi avatar peckb1 avatar polyatail avatar shauniarima avatar staktrace avatar sullis avatar swankjesse avatar szabado-faire avatar tcollier avatar tso avatar tyiu avatar yissachar avatar zhxnlai avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar

tempest's Issues

Tempest coerces the type of records when querying a GSI

Tempest assumes the type of records when querying a GSI, and coerces all records to be that type without checking the entity sort key prefix.


Example

Let's say we have a library hold system that tracks holds on Books and Movies. We might have:

  • Book Hold
    • Hold Token (PK): String
    • Book Token: String
    • Hold placed at: Instant
    • Title, author, other metadata
  • Movie Hold
    • Hold Token (PK): String
    • Movie Token: String
    • Hold placed at: Instant
    • Director, actors, other metadata

When a loaned book/movie gets returned, the library would need to give it to the next person in line. To faciliate that, we'd need a GSI. We could build one for books and one for movies, but the idiomatic dynamo approach would be to share a GSI, and have the following schema:

  • Book/movie token (PK)
  • Hold placed at (SK)

That way the system can easily allocate the pending holds by looking at the oldest hold for a given book.


The Problem

Tempest has no type safety here. If you look up book_token_123 in the tempest movie GSI, it'll try to return a book (ignoring the sort key prefix), and can very well succeed if you have enough nullable fields.

This surfaced for me as a bug - I bumped the schema version on my table (including giving it a new sort key prefix), and then my code proceeded to pull the old records out of the table, coerced as new records, and caused lots of mayhem.


Solutions

This feels like it's safely in bug territory but I wanted to consult with you folks on solutions before putting up a fix. In my mind tempest just needs to be checking that the correct prefix exists before using the Codec to parse it.

Support for specifying table name at runtime

There does not seem to be a way to specify a table name except via. annotations that cannot be modified at runtime. We have different table names in different environments (dev, qa, prod) and would like to be able to set the table name at runtime via e.g., a configuration file.

Pagination doesn't work when querying a GSI with prefixed sort key

We encountered a failing non-null assertion in tempest's Prefixer when performing a query on a GSI using a prefixed sort key, once the result set grew large enough to require pagination, causing our service to be unable to read from the database.

Analysis by @jialiang-cash found that the LastEvaluatedKey object returned from Dynamo only includes the partition_key and secondary_index_keys fields. This caused an issue in decodeOffset when Tempest tries to remove the prefixes on both partition_key and sort_key, resulting in a null value exception because no sort_key exists.

Stack trace:

java.lang.IllegalArgumentException: Required value was null.
	at app.cash.tempest.internal.ReflectionCodec$Prefixer.removePrefix(Codec.kt:137)
	at app.cash.tempest.internal.ReflectionCodec.toApp(Codec.kt:92)
	at app.cash.tempest2.internal.DynamoDbQueryable.decodeOffset(DynamoDbQueryable.kt:153)
	at app.cash.tempest2.internal.DynamoDbQueryable.toQueryResponse(DynamoDbQueryable.kt:114)
	at app.cash.tempest2.internal.DynamoDbQueryable.access$toQueryResponse(DynamoDbQueryable.kt:39)
	at app.cash.tempest2.internal.DynamoDbQueryable$Sync.query(DynamoDbQueryable.kt:68)
	at app.cash.tempest2.internal.LogicalDbFactory$SecondaryIndexFactory$secondaryIndex$1.query(LogicalDbFactory.kt)
	at app.cash.tempest2.Queryable$DefaultImpls.query$default(Query.kt:27)

This is the assertion that's failing:

val attributeValue = requireNotNull(attributeValues[attributeName])

BatchLoad for AsyncDB needs a way to concat publishers

Work for #123 added paging support for the batch methods in the LogicalDb. However, since the AsyncDB uses a Publisher for batchLoad in order to return a paged response we would need to concatenate each page serially. I don't know how to do this without blocking. The Publisher interface comes form HHH, and is implemented by some popular libraries like ReactiveX and reactor, which could offer a mechanism for this.

Support for AWS SDK 2.x

Currently Tempest depends on com.amazonaws:aws-java-sdk-dynamodb which is the old AWS SDK for Java. There is a [new SDK][https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/home.html] and it would be great if Tempest could support it (perhaps along side with the old SDK even)

Improve exception message for invalid unique key for [local,global] secondary index offset

I was a bit confused by the below exception which showed up when I added the following GSI. An improved exception message that includes the mapped field name and points out that both partition and sort key are required in the index offset (even as empty string) to provide a unique key for lookup.

data class HashedBravo(
  @Attribute(
    name = BravoDb.PARTITION_KEY_ATTRIBUTE_NAME,
    prefix = BravoDb.PREFIX_APP_TOKEN
  ) val app_token: String,
  @Attribute(
    name = BravoDb.SORT_KEY_ATTRIBUTE_NAME,
    prefix = BravoDb.PREFIX_HASHED_ALIAS
  ) val hashed_alias: String,
  val version: Long? = null,
  val hashed_alias_id: Long? = null,
) {
  data class Key(
    val app_token: String,
    val hashed_alias: String = "",
  )

  @ForIndex(BravoDb.HASHED_ALIAS_ID_INDEX)
  data class IdIndexOffset(
    val hashed_alias_id: Long,
    val app_token: String = "",
//    val hashed_alias: String = "",      // without this, the following exception shows up since there's no sort key on this index, wasn't intuitive to me why this was needed when my lookup query would never have it
  )
}

Notably, the sort_key in the exception message doesn't have the name mapped to the above @Attribute configured field name hashed_alias.

Unable to provision, see the following errors:

1) Error in custom provider, java.lang.IllegalArgumentException: Expect class bravo.service.persistence.dynamo.HashedContact$IdIndexOffset to have property sort_key
  at bravo.service.persistence.BravoStoreModule$ForDynamoTesting.provideBravoStore(BravoStoreModule.kt:57) (via modules: misk.testing.MiskTestExtension$beforeEach$module$1 -> bravo.service.BravoTestingModule$WithDynamoStore -> bravo.service.persistence.BravoStoreModule$ForDynamoTesting)

Query hangs for missing attributes

This is probably an edge case, but if a record does not have a matching attribute in the data class for AsyncInlineView, the query just "hangs". I don't know if there is a timeout that kicks in after some time, but it doesn't return at least for a 30 seconds.

This happens if we change the "schema" of a table. For eg, if we have something like this:

data class PlaylistInfo(
  @Attribute(name = "partition_key")
  val playlist_token: String,
  val playlist_name: String,
  val playlist_tracks: List<AlbumTrack.Key>
)

and later add a new attribute:

data class PlaylistInfo(
  @Attribute(name = "partition_key")
  val playlist_token: String,
  val playlist_name: String,
  val playlist_tracks: List<AlbumTrack.Key>,
  val playlist_version: Long
)

If we try to read the old records using the new format it hangs (even if we make playlist_version nullable and provide a default value).

It should throw an exception in these cases instead of hanging. Also it would be nice to be able to read old records if the new attributes are nullable.

Migration guide from Tempest v1 to v2

It appears that there are some breaking changes with dynamodb v2 apis that led to the same breaking changes in tempest v2. It would be beneficial to have a migration guide for customers on v1 to help them move over to v2.

Ambiguous error message when dealing with immutable properties

As we discussed on Slack, I ran into this stacktrace:

class kotlin.reflect.jvm.internal.KProperty1Impl cannot be cast to class kotlin.reflect.KMutableProperty1 (kotlin.reflect.jvm.internal.KProperty1Impl and kotlin.reflect.KMutableProperty1 are in unnamed module of loader 'app')
java.lang.ClassCastException: class kotlin.reflect.jvm.internal.KProperty1Impl cannot be cast to class kotlin.reflect.KMutableProperty1 (kotlin.reflect.jvm.internal.KProperty1Impl and kotlin.reflect.KMutableProperty1 are in unnamed module of loader 'app')
	at app.cash.tempest.internal.Binding.setDb(Codec.kt:188)

which didn't make it very obvious that the root cause was that I needed to change a val to a var.

Support for flattening objects

The official DynamoDB mapper (at least the 2.x one) has a DynamoDbFlatten that instructs it to flatten the specified property out into a single row:

@DynamoDbBean
data class BookItem(
  @get:DynamoDbPartitionKey
  var iban: String? 
  var name: String? = null,
  @get:DynamoDbFlatten
  var author: Author? = null
) {
  @get:DynamoDbSortKey
  var sortKey: String = "book"

  @DynamoDbBean
  data class Author(
    @get:DynamoDbAttribute("authorName")
    var name: String? = null,
    @get:DynamoDbAttribute("authorNationality")
    var nationality: String? = null
  )
}

It would be nice if Tempest provided either an automatic or manual mapping of these nested types.

Empty sort key on a secondary index causes a useless error that makes it difficult to pinpoint the cause.

The original error:
1) [Guice/ErrorInCustomProvider]: NoSuchElementException: No value present
which, sounds like a user error, must have set it up wrong, but "No value present" sounds like an empty java optional, not a guice problem.
https://cash.slack.com/archives/C02FPF8FL8M/p1647971686751099
here's the full discussion, but it took multiple people and the better part of the day to track down the actual issue

internal class V2RawItemTypeFactory : RawItemType.Factory {

  override fun create(tableName: String, rawItemType: KClass<*>): RawItemType {
    val tableSchema = TableSchemaFactory.create<Any>(rawItemType.java)
    return RawItemType(
      rawItemType as KClass<Any>,
      tableName,
      tableSchema.tableMetadata().primaryPartitionKey(),
      tableSchema.tableMetadata().primarySortKey().orElse(null),
      tableSchema.attributeNames().sorted(),
      tableSchema.tableMetadata().indices()
        .filter { it.name() != TableMetadata.primaryIndexName() }
        .map {
          if (it.sortKey().isEmpty) {
            throw IllegalArgumentException("Secondary Index cannot be created, sortKey must be set.")
          }
          ItemType.SecondaryIndex(
            it.name(),
            it.partitionKey().orElse(null)?.name() ?: tableSchema.tableMetadata().primaryPartitionKey(),
            it.sortKey().get().name() 
          )
        }.associateBy { it.name }
    )
  }
}

Here: 6th line from the bottom: it.sortKey().get().name() there is no check on the .get(), and if the sort key isn't set (do you always need a sort key on a secondary index? its an Optional<>?) this throws in an unhelpful way.

a change, from me who has no understanding of the system at large -

if (it.sortKey().isEmpty) {
            throw IllegalArgumentException("Secondary Index cannot be created, sortKey must be set.")
          }

would at least give back a more useful message in the stack trace, but perhaps the sort key just shouldn't be an Optional? or there should be a logic branch here to handle if it is empty.

Update docs to support Tempest 2

Tempest 2 is substantially similar to Tempest, but different enough that the docs are often misleading. I found myself looking through the examples most of the time trying to make sense of what I wanted to do, instead of the docs.

Scanning views crashes when offset is of a different type

I created a heterogeneous table of albums and tracks.

I scanned tracks and it crashed because one of the results was an album INFO_ item. So I added a filter expression.

Then I scanned and it crashed because the offset was an album INFO_, but scan attempted to convert it to a track key.

Tempest fails to find TableName annotation in Java 21.0.1 and Kotlin 1.9.21

Hi team! Reporting a bug I ran into trying to use tempest on Java 21.0.1 and Kotlin 1.9.21. Tempest (and any code I write) is unable to find the @TableName annotation on classes that implement LogicalDb.

This is reproducible for me when using the code from the getting started guide. Given:

interface AliasDb : LogicalDb {
  @TableName("alias_items")
  val aliasTable: AliasTable
}

interface AliasTable : LogicalTable<AliasItem> {
  val aliases: InlineView<Alias.Key, Alias>
}

data class Alias(
  val short_url: String,
  val destination_url: String
) {
  data class Key(
    val short_url: String 
  )
}

calling AliasDb::aliasTable.annotations returns an empty list. This issue is only present when implementing specifically LogicalDb. Implementing LogicalTable.Factory instead, the list contains the single annotation as expected.

My hunch is that there's some backwards-incompatible change in between Kotlin 1.7.10 and Kotlin 1.9.21 that impacts reflection. It might have something to do with the difference between the way default implementations on interfaces are compiled. I suggest that because it's the biggest difference in the decompiled java code when comparing:

  • An interface that implements your version of LogicalDb
  • An interface that implements a version of LogicalDb that I copy/pasted with no changes
Screenshot of decompiled code diff Screenshot 2024-03-14 at 8 56 47 AM

Here's a test that can reproduce the failures for me:

import app.cash.tempest2.LogicalDb
import org.junit.jupiter.api.Test

@Retention(AnnotationRetention.RUNTIME)
private annotation class TableName(
    val value: String = ""
)

private interface BrokenInterface : LogicalDb {
  @TableName("alias_items")
  val aliasTable: String
}

private interface WorkingInterface{
  @TableName("alias_items")
  val aliasTable: String
}

internal class LogicalDbTest {
  @Test
  fun `annotation weirdness`() {
    // Test fails
    assert(BrokenInterface::aliasTable.annotations.isNotEmpty())
  }

  @Test
  fun `expected behaviour`() {
    // Test passes
    assert(WorkingInterface::aliasTable.annotations.isNotEmpty())
  }
}

I think the main factors here are the Java/Kotlin versions I'm using, but happy to provide further details as needed.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.