Skip to content

Refactor validator creation to support various configurations #283

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

Merged
merged 15 commits into from
May 14, 2025

Conversation

smaye81
Copy link
Member

@smaye81 smaye81 commented May 9, 2025

Fixes #51

This makes a series of changes to the way that a validator is created. Doing so enables more flexible ways to configure a validator and also provides a bit more safety when attempting to create a validator with invalid state. The impetus behind this change was to allow for the ability to specify a list of seed descriptors when building a validator and for the optional ability to disable lazy-loading of any descriptors not provided.

Previously, a validator could be created either with a default constructor (new Validator()) or via a Config object (new Validator(config)). However, the Config object had no way to provide a list of descriptors, it only had a disableLazy field. Simply adding a list of descriptors to the Config presented a few problems though:

  • Since these were two unrelated fields in the config, users had a serious footgun to play with because it was possible to set disableLazy to true and not specify any descriptors. Doing so would return an error at validation time with a validation message about an unknown evaluator.
  • Even if the above was tolerable, building validation rules from descriptors necessitates catching a CompilationException. This meant that if the seed descriptors were added to the config, all users creating a validator would now have to catch a checked exception even if they weren't using the seed descriptors.

The solution in this PR converts validator creation from using constructors into a builder pattern with the following changes:

  • disableLazy and the new descriptors list have been moved from the config and are now parameters at instantiation time.
  • There are two options for building once the builder is created:
    • build()
    • buildWithDescriptors(List<Descriptor>, boolean disableLazy) throws CompilationException, InvalidStateException

The overload that accepts seed descriptors is the only one that throws a CompilationException. If users choose to use build with no arguments, no checked exception needs to be handled.

Note that the above also allows us to provide a helpful check at instantiation time if a user sets disableLazy to true but doesn't provide any descriptors. In this case, an IllegalStateException is thrown. It is still possible to provide an incorrect list of descriptors, but that is impossible to catch at instantiation time (but hopefully is now a bit harder to do).

The new API now adds a ValidatorFactory class with static methods for creating a validator. In addition, the type returned is now an interface (still named Validator) which provides us with a bit more flexibility both for testing and for making non-breaking changes in the future.

Examples of creating a validator with no seed descriptors:

// Create a Validator with a default config
Validator validator = ValidatorFactory.newBuilder().build();

// Create a Validator with a custom config
Config cfg = Config.newBuilder().setFailFast(true).build();
Validator validator = ValidatoryFactory.newBuilder().withConfig(cfg).build();

Creating a validator with seed descriptors is similar. You would still call newBuilder, but at build time, you call buildWithDescriptors and pass the descriptors and set disableLazy accordingly. Using this approach requires catching exceptions at build time:

// Create a validator with seed descriptors
MyMessage msg = MyMessage.newBuilder().build();
List<Descriptor> seedDescriptors = new ArrayList<Descriptor>();
seedDescriptors.add(msg.getDescriptorForType());

try {
    Validator validator = ValidatorFactory.newBuilder().buildWithDescriptors(seedDescriptors, false);

    // Note you can also create a validator with a config
    // Validator validator = ValidatorFactory.newBuilder().withConfig(cfg).buildWithDescriptors(seedDescriptors, false);

    // Also note that the buildWithDescriptors function is what throws the exception so the validator 
    // builder can be created ahead of time with no need for catching an exception.
} catch (CompilationException ce) {
    // Exception pre-warming the cache with the seed descriptors
} catch (IllegalStateException ise) {
    // This is thrown if seedDescriptors is empty and disableLazy is true
}

Note: THIS IS A BREAKING CHANGE, but the hope is that this makes it easier and more intuitive for creating a validator while providing future-proof flexibility. To migrate, users should make the following changes:

With no config

import build.buf.protovalidate.Validator;
+ import build.buf.protovalidate.ValidatorFactory;

- Validator validator = new Validator();
+ Validator validator = ValidatorFactory.newBuilder().build();

With config

import build.buf.protovalidate.Validator;
+ import build.buf.protovalidate.ValidatorFactory;

Config cfg = Config.newBuilder().setFailFast(true).build();
- Validator validator = new Validator(cfg);
+ Validator validator = ValidatorFactory.newBuilder().withConfig(cfg).build();

In addition, the loadMessages and loadDescriptors methods from the validator have been removed. Users should instead use the newBuilder(List<Descriptor> descriptors, boolean disableLazy) approach to load descriptors ahead of time.

@@ -51,6 +51,7 @@ class EvaluatorBuilder {

private final Env env;
private final boolean disableLazy;
private final List<Descriptor> descriptors;
Copy link
Member

Choose a reason for hiding this comment

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

This only looks to be referenced in the constructor. We should be able to remove this field and just iterate over the constructor arg below.

* be created with a list of seed descriptors to warmup the validator cache ahead of time as well as
* an indicator to lazily-load any descriptors not provided into the cache.
*/
public class ValidatorFactory {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
public class ValidatorFactory {
public final class ValidatorFactory {

public class ValidatorFactory {
// Prevent instantiation
private ValidatorFactory() {}
;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
;

* @throws IllegalStateException If disableLazy is set to true and no descriptors are passed.
*/
public Validator build() throws CompilationException, IllegalStateException {
if (disableLazy && this.descriptors.size() == 0) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if (disableLazy && this.descriptors.size() == 0) {
if (disableLazy && this.descriptors.isEmpty()) {

}

/** An abstract class that all validator builders should extend. */
abstract class BaseBuilder<T extends BaseBuilder<T>> {
Copy link
Member

Choose a reason for hiding this comment

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

This should be made static and lifted into the ValidatorFactory class or moved to a separate .java file.

* makes the entire cache read-only, eliminating thread contention.
* @return A Validator builder
*/
public static EagerBuilder newBuilder(List<Descriptor> descriptors, boolean disableLazy) {
Copy link
Member

Choose a reason for hiding this comment

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

Perhaps a simpler design would be to have a single builder class, with two build methods:

public Validator build() {}

public Validator buildWithDescriptors(List<Descriptor> descriptors, boolean disableLazy) throws CompilationException, IllegalStateException;

Copy link
Member Author

Choose a reason for hiding this comment

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

That's fair. To do that, we'd have to create the EvaluatorBuilder in the builder and keep it in state so that it can be passed to the ValidatorImpl constructor. Not sure if that's desirable or not. It definitely simplifies the design but at the cost of having more logic in the actual builder itself. Wdyt?

* @return A Validator instance
*/
public static Validator defaultInstance() {
return newBuilder().build();
Copy link
Member

Choose a reason for hiding this comment

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

I think we should leave this out - it isn't that much more to type to have someone just do ValidatorFactory.newBuilder().build(). We could always add it back if desired.

This method as written feels like it is returning the same instance each time (similar to the global validator in pv-go: https://pkg.go.dev/buf.build/go/protovalidate#Validator), not a new instance each time w/ the default config.

@smaye81 smaye81 requested a review from pkwarren May 14, 2025 15:36
this.disableLazy = disableLazy;
this.rules = new RuleCache(env, config);

for (Descriptor descriptor : this.descriptors) {
this.build(descriptor);
if (descriptors != null) {
Copy link
Member

Choose a reason for hiding this comment

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

The nil check isn't needed here - if anything we could add an Objects.requireNonNull(descriptors) above if we wanted with an error message. Doesn't hurt but typically you never pass around null collections.

Copy link
Member Author

Choose a reason for hiding this comment

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

Without the check, it throws an NPE though: java.lang.NullPointerException: Cannot invoke "java.util.List.iterator()". I think we should do something here to catch that. Seems like an easy thing to prevent.

Copy link
Member

Choose a reason for hiding this comment

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

This would only happen though if someone passes null to the buildDescriptors method, right?

Typically if you wanted a nicer error you'd do Objects.requireNonNull(descriptors, "descriptors must not be null") as the first line in the constructor (or the equivalent Preconditions.checkNotNull from Guava).

Copy link
Member Author

Choose a reason for hiding this comment

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

OK, removed the null check and added the requireNonNull check instead.

@smaye81 smaye81 merged commit ff29603 into main May 14, 2025
4 checks passed
@smaye81 smaye81 deleted the sayers/disable_lazy branch May 14, 2025 17:15
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.

[BUG] Config.disableLazy isn't functional as written
2 participants