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

Handle compare_by option #154

Merged
merged 188 commits into from
Oct 16, 2024
Merged

Handle compare_by option #154

merged 188 commits into from
Oct 16, 2024

Conversation

yevhenii-nadtochii
Copy link
Contributor

@yevhenii-nadtochii yevhenii-nadtochii commented Aug 27, 2024

This PR adds support for Comparable messages (#152).

Now, we have mc-java-comparable plugin, which discovers messages with compare_by option and generates code to make them comparable.

The following render actions are applied:

  1. ImplementComparable – adds Comparable<...> interface to the message.
  2. AddComparator – adds a private static field containing the Comparator<...> itself.
  3. AddCompareTo – adds an overridden method accordingly to the interface contract.

External messages

An external message is a message, which is not being generated by the current codegen session. Usually, they come from the dependencies. For example, google.protobuf.Timestamp. When a user wants to use a field in comparison, the type of which is external, a comparator should be provided within the ComparatorRegistry. By default, the registry provides comparators for Timestamp and Duration only.

If the external message has compare_by option (our messages, generated by mc-java as well), then the presence of comparator is not required. Though, the plugin will throw if there is the option and comparator in the registry simultaneously.

Concerns

  1. AddComparator: this is the smartest action. It performs analysis of Proto messages, indeed. I'm not sure if it should be done in an entity of DirectMessageAction type.
  2. ComparableActionsRenderer: can we store actions in one place? Our model doesn't specify a distinct set of actions for each message, and probably wouldn't. We carry the same collection of action classes in each discovered message. One could think that each message may have its own set of actions.

Other changes

implementsInterface assertion is stricter now, requiring the asserted interface to be located before an opening brace of the class body.

@yevhenii-nadtochii yevhenii-nadtochii self-assigned this Aug 27, 2024
@yevhenii-nadtochii yevhenii-nadtochii linked an issue Aug 27, 2024 that may be closed by this pull request
@yevhenii-nadtochii
Copy link
Contributor Author

@armiol @alexander-yevsyukov PTAL

Also, I suggest overriding RenderAction.context and RenderAction.typeSystem, so that they are non-nullable for actions. Mark them as open in Member, and override in RenderAction. The action calls registerWith(context) in its init block.

Copy link
Contributor

@armiol armiol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yevhenii-nadtochii please see my comments so far. I will continue the review in IDE, as it's difficult to follow via GitHub.

* ```
*
* Please note the line breaks were added manually for reader's convenience.
* The builder doesn't add them. It will be a single line of text.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the code is automatically formatted by PSI, will it still be a single line of text?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see in integration tests output that the comparator remains unformatted.

This comment relates to this particular builder so that no one expected build(): String to return a string as from the KDoc snippet.

* The builder doesn't add them. It will be a single line of text.
*
* @param cls The message class to be used as comparator's generic parameter.
* @param descending If true, the default order is reversed.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which order is a "default" one?

* The builder doesn't add them. It will be a single line of text.
*
* @param cls The message class to be used as comparator's generic parameter.
* @param descending If true, the default order is reversed.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure, but if you explain "descending" as "in a reversed order", maybe the parameter should be called something like reversed?

And honestly, "descending" is more about sorting to me.

Copy link
Contributor

@alexander-yevsyukov alexander-yevsyukov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see my comments.

val currentField = message.field(fieldPath.root)
val remainingFields = fieldPath.fieldNameList.drop(1)
val remainingPath = fieldPath { fieldName.addAll(remainingFields) }
val nextMessage = findMessage(currentField.type.message)!!.first
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the referenced field is not of message type? I believe we need to tell about such an error.

* The function returns a non-`null` result if a class denoted by this [ClassName]
* is present on the classpath.
*/
public fun MessageType.javaClass(accordingTo: ProtoFileHeader): Class<*>? {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This extension should go to ProtoData too.

}

else -> error(
"The field `$path` has an unrecognized Proto field type: `$type`. " +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to standardize on capitalization of the word "proto". I think in the short form it should go in lowercase, while "Protobuf" is capitalized. This is the way Google use it in their sources, from what I remember.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I'm not sure about "unrecognized". We do recognize the type because it's in the source code, descriptors, and is (at least) proto-compilable. Otherwise, we wouldn't have got this far.

But this type isn't probably "comparable" because it's a list, or a map.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This unrecognized is a stub error thrown when ast.Type has an unknown kind. It is not about a non-comparable type.

kind is represented by oneof, which has nothing to do with Kotlin's when (as for example, with sealed classes):

message Type {

    oneof kind {
        option (is_required) = true;

        TypeName message = 1;

        TypeName enumeration = 2;

        PrimitiveType primitive = 3;
    }
}

So, we have to provide else branch in case some new kind appears.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. I get it. The problem it is buried inside extensions, and is not obvious from the branching here. Hence, my comment.


else -> error(
"The field `$path` has an unrecognized Proto field type: `$type`. " +
"The supported Proto fields: primitives, enums and messages."
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suppose you're the person who gets this error message in console. What it would take to figure out the root cause of the error. Let's sum what we see in the diag message:

  1. "Unrecognized type".
  2. It's not "supported".
  3. Supported types are primitives, enums, and messages. Please notice Oxford comma in the enumeration.
  4. Stacktrace having "comparable".

In the end the combination above would give the reader idea that the field isn't probably comparable.
But can we make this path to the conclusion a bit shorter?

Copy link
Contributor

@alexander-yevsyukov alexander-yevsyukov Oct 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed the improved wording for the error message:

The field `$path` declared in the message type `$messageType` 
is not of the supported type (`$fieldType`) for the comparison. 
Supported field types are: primitives, enums, and messages.

* Adds the comparison [field] to this [ComparatorBuilder].
*
* The requirements to the comparison fields are described in docs to [CompareByOption]
* option in detail.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reference to documentation details should probably be a part of the error diagnostics I mentioned above.

when {
hasCompareByOption -> {
check(fromRegistry == null) {
"The type of `${path.joined}` field must either have `compare_by` options OR " +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This message is good!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also consider my comment about braces around the option name here.


fromRegistry != null -> {
val className = "${clazz.canonicalName}.class"
val comparator = "io.spine.compare.ComparatorRegistry.get($className)"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use the reference extension to the ComparatorRegistry class here? This way finding usages would pick up this dependency.

@yevhenii-nadtochii
Copy link
Contributor Author

@armiol @alexander-yevsyukov PTAL

I will try bringing typed expressions to ComparatorBuilder with a separate PR. TypeSystem.resolve(path) also would go there when it is published.

Copy link
Contributor

@alexander-yevsyukov alexander-yevsyukov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with a couple of comments regarding diag. messages to consider before merging.


is MessageComparisonField -> {
check(field.type.hasCompareByOption) {
"The type of `${path.joined}` field should have `compare_by` option itself " +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually put the option in round braces when mentioning in the docs or diagnostic messages. I don't know who started this, and we probably don't have it standardized in our guidelines. But (compare_by) looks more visible and is clearly different to a field.

Also, the definite article should be probably put before the name.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@armiol, for your attention.

when {
hasCompareByOption -> {
check(fromRegistry == null) {
"The type of `${path.joined}` field must either have `compare_by` options OR " +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also consider my comment about braces around the option name here.

private fun unsupportedFieldType(fieldPath: FieldPath, fieldType: Any?): Nothing = error(
"The field `$fieldPath` declared in the message `$type` is not of the supported type " +
" (`$fieldType`) for the comparison. Supported field types are: " +
"primitives, enums, and comparable messages. Checks out docs to `compare_by` " +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please consider braces around the option name in this message.

Copy link
Contributor

@armiol armiol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yevhenii-nadtochii LGTM.

However, please see my comment. Maybe, that's something we need to discuss.


@Nested
inner class
`not generate comparator` {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do we think of oneof?

It's now in a gray zone. I would either have tests explicitly covering our support or our refusal to support the comparison by oneof fields.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added the negative tests to cover oneof. Its support was not declared.

@yevhenii-nadtochii yevhenii-nadtochii merged commit 867cb60 into master Oct 16, 2024
7 checks passed
@yevhenii-nadtochii yevhenii-nadtochii deleted the handle-comparable-option branch October 16, 2024 13:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Implement support for Comparable messages
3 participants