Creating a Code Generator Processor - Code Generator [3/4]

In this tutorial, we're diving deep into the implementation of the Code Generator Processor, a crucial component of our annotation-based code generation system. If you're new here, I recommend checking out our previous tutorials to grasp the foundational concepts that led us to this point.

Understanding the Data Access Layer (DAO)

Before we plunge into the nitty-gritty of our processor, let's quickly recap the concept of Data Access Objects (DAO). These classes interact directly with databases, providing methods for CRUD operations: Create, Read, Update, and Delete. For instance, take a look at this sample DAO class:


package org.example;

import org.example.StudentRequestException;
import org.example.StudentServiceException;

import java.io.EOFException;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

public class StudentDaoImpl implements StudentDao {
   private final String dbFile = "student.db";

   @Override
   public void add(Student student) throws StudentRequestException, StudentServiceException {
       StudentGeneratedDto studentGeneratedDto = convertStudentToStudentGeneratedDto(student);
       List<Student> students = getAll();
       students.add(student);
       try (FileOutputStream fileOut = new FileOutputStream(dbFile);
            ObjectOutputStream objectOut = new ObjectOutputStream(fileOut)) {
           for (Student random : students) {
               objectOut.writeObject(convertStudentToStudentGeneratedDto(random));
           }
       } catch(IOException e) {
           throw new StudentServiceException("Internal service error, please try again!" + e);
       }
   }

   @Override
   public List<Student> getAll() throws StudentRequestException, StudentServiceException {
       List<Student> students = new ArrayList<>();
       if (!this.dbExist()) {
           return students;
       }
       try (FileInputStream fileIn = new FileInputStream(dbFile);
            ObjectInputStream objectIn = new ObjectInputStream(fileIn)) {
           while(true) {
               try {
                   StudentGeneratedDto studentGeneratedDto = (StudentGeneratedDto) objectIn.readObject();
                   students.add(convertStudentGeneratedDtoToStudent(studentGeneratedDto));
               } catch (EOFException eof) {
                   break;
               }
           }
       } catch(IOException | ClassNotFoundException e) {
           throw new StudentServiceException("Internal service error occurred: " + e);
       }
       return students;
   }

   @Override
   public void delete(Student student) throws StudentRequestException, StudentServiceException {
       if (!this.dbExist()) {
           throw new StudentRequestException("Unable to find the student");
       }
       boolean isDeleted = false;
       List<Student> students = this.getAll();
       try (FileOutputStream fileOut = new FileOutputStream(dbFile);
            ObjectOutputStream objectOut = new ObjectOutputStream(fileOut)) {
           for (Student random : students) {
               if(!(random.getRollNumber() == student.getRollNumber())) {
                   objectOut.writeObject(convertStudentToStudentGeneratedDto(random));
               } else {
                   isDeleted = true;
               }
           }
       } catch (IOException e) {
           throw new StudentServiceException("Internal service error occurred: " + e);
       }

       if (!isDeleted) {
           throw new StudentRequestException("Data not found: ");
       }
   }

   @Override
   public void update(Student student) throws StudentRequestException, StudentServiceException {
       if (!this.dbExist()) {
           throw new StudentRequestException("Unable to find the student");
       }
       boolean isUpdated = false;
       List<Student> students = this.getAll();
       try (FileOutputStream fileOut = new FileOutputStream(dbFile);
            ObjectOutputStream objectOut = new ObjectOutputStream(fileOut)) {
           for (Student random : students) {
               if(random.getRollNumber() == student.getRollNumber()) {
                   objectOut.writeObject(convertStudentToStudentGeneratedDto(student));
                   isUpdated=true;
               } else {
                   objectOut.writeObject(convertStudentToStudentGeneratedDto(random));
               }
           }
       } catch (IOException e) {
           throw new StudentServiceException("Internal service error occurred: " + e);
       }

       if (!isUpdated) {
           throw new StudentRequestException("Data not found: ");
       }
   }

   private boolean dbExist() {
       File file = new File(dbFile);
       return file.exists();
   }

   private Student convertStudentGeneratedDtoToStudent(StudentGeneratedDto studentGeneratedDto) {
       Student student = new Student();
       student.setName(studentGeneratedDto.getName());
       student.setRollNumber(studentGeneratedDto.getRollNumber());
       return student;
   }

   private StudentGeneratedDto convertStudentToStudentGeneratedDto(Student student) {
       StudentGeneratedDto studentGeneratedDto = new StudentGeneratedDto();
       studentGeneratedDto.setName(student.getName());
       studentGeneratedDto.setRollNumber(student.getRollNumber());
       return studentGeneratedDto;
   }

}

Processor Implementation - Setup

Firstly, we'll identify the model classes that use the @FileDBGenerated annotation. To do so, we'll make use of the roundEnv variable provided by the Java compiler. Additionally, we'll ensure that the @FileDBGenerated annotation is applied at the class level and not on a field or any other resource.


{
    Set<? extends Element> fileDbGeneratedAnnotatedClasses = roundEnv.getElementsAnnotatedWith(FileDBGenerated.class);
    for (Element element : fileDbGeneratedAnnotatedClasses) {
        if (element.getKind() == ElementKind.CLASS) {
        
        }
    }
}

Once we identify the class using the @FileDBGenerated annotation and confirm that it's at the class level, we'll proceed to extract the fields annotated with the @Persisted and @UniqueKey annotations. In this context, we'll assume that any field annotated with @UniqueKey is also annotated with @Persisted. Subsequently, we will store these identified fields in a list for use in a later stage.


{
    TypeElement classElement = (TypeElement) element;
    
    List<VariableElement> fields = new ArrayList<>();
    List<VariableElement> uniqueKeyFields = new ArrayList<>();
    for (Element enclosedElement : classElement.getEnclosedElements()) {
        if (enclosedElement.getKind() == ElementKind.FIELD
                && enclosedElement.getAnnotation(UniqueKey.class) != null) {
            VariableElement fieldElement = (VariableElement) enclosedElement;
            uniqueKeyFields.add(fieldElement);
        } else if (enclosedElement.getKind() == ElementKind.FIELD
                && enclosedElement.getAnnotation(Persisted.class) != null) {
            VariableElement fieldElement = (VariableElement) enclosedElement;
            fields.add(fieldElement);
        }
    }
}

Next, we will proceed to extract the name of the model class along with its package name. The model class's name will be utilized for naming our generated classes, while the package name will help identify the location where these generated classes will be stored. To illustrate, if the model class name is "Student," our generated classes will be prefixed with "Student."


TypeElement enclosingClass = (TypeElement) fields.stream().findAny().get().getEnclosingElement();
this.packageName = processingEnv.getElementUtils().getPackageOf(enclosingClass).toString();
this.className = enclosingClass.getSimpleName().toString();

And that concludes the setup. We now possess all the necessary information required to generate our classes. Here is the final code presented together.


@SupportedAnnotationTypes("com.gogettergeeks.annotation.FileDBGenerated")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class FileDBProcessor extends AbstractProcessor {
    private String packageName;
    private String className;

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        boolean isClaimed = false;
        Set<? extends Element> fileDbGeneratedAnnotatedClasses = roundEnv.getElementsAnnotatedWith(FileDBGenerated.class);
        for (Element element : fileDbGeneratedAnnotatedClasses) {
            if (element.getKind() == ElementKind.CLASS) {
                TypeElement classElement = (TypeElement) element;

                List<VariableElement> fields = new ArrayList<>();
                List<VariableElement> uniqueKeyFields = new ArrayList<>();
                for (Element enclosedElement : classElement.getEnclosedElements()) {
                    if (enclosedElement.getKind() == ElementKind.FIELD
                            && enclosedElement.getAnnotation(UniqueKey.class) != null) {
                        VariableElement fieldElement = (VariableElement) enclosedElement;
                        uniqueKeyFields.add(fieldElement);
                    } else if (enclosedElement.getKind() == ElementKind.FIELD
                            && enclosedElement.getAnnotation(Persisted.class) != null) {
                        VariableElement fieldElement = (VariableElement) enclosedElement;
                        fields.add(fieldElement);
                    }
                }

                if (!fields.isEmpty()) {
                    TypeElement enclosingClass = (TypeElement) fields.stream().findAny().get().getEnclosingElement();
                    this.packageName = processingEnv.getElementUtils().getPackageOf(enclosingClass).toString();
                    this.className = enclosingClass.getSimpleName().toString();
                }
            }
        }

        return isClaimed;
    }
}

Implementing the Processor - Generate DAO

To maintain the tutorial's conciseness, I will demonstrate how to generate the DAO class. For the remaining classes, you can refer to the complete processor code available here.

To ensure organized logic, we'll create private methods within the processor class. Alternatively, you can opt to place these private methods in separate utility classes.

The conceptual approach for generating the class is straightforward: we retain the concrete implementation as reference and carefully examine the code line by line. Within each line, we identify the dynamic components and generalize them. To illustrate, let's commence with the initial line of the concrete implementation, which defines the package.


1.  package org.example;

3.  import java.io.EOFException;
4.  import java.io.File;
5.  import java.io.FileInputStream;
6.  import java.io.FileOutputStream;
7.  import java.io.IOException;
8.  import java.io.ObjectInputStream;
9. import java.io.ObjectOutputStream;

10. import java.util.ArrayList;
11. import java.util.List;
12. import java.util.UUID;

To generate the first line, we recognize that the package will remain constant, while "org.example" will be based on the package name of the model class. Consequently, the generic implementation will resemble the following:


private void generateDao(List<VariableElement> fields, List<VariableElement> uniqueKeyFields) {
    StringBuilder body = new StringBuilder();
    body.append("package ").append(this.packageName).append(";

");
}

If you observe closely, we will be consolidating all the generic code within the StringBuilder. Once we complete the entire logic, we will conveniently write this string to a file (which we will cover later).

Subsequently, lines #3 through #12 will remain consistent across all implementations. Consequently, we will maintain them without any alterations.


private void generateDao(List<VariableElement> fields, List<VariableElement> uniqueKeyFields) {
    StringBuilder body = new StringBuilder();
    body.append("package ").append(this.packageName).append(";

");
    body.append("
import java.io.EOFException;
");
    body.append("import java.io.File;
");
    body.append("import java.io.FileInputStream;
");
    body.append("import java.io.FileOutputStream;
");
    body.append("import java.io.IOException;
");
    body.append("import java.io.ObjectInputStream;
");
    body.append("import java.io.ObjectOutputStream;

");
    body.append("import java.util.ArrayList;
");
    body.append("import java.util.List;
");
    body.append("import java.util.UUID;

");
}

Next, I will illustrate how to generate the throws Exception1, Exception2 statements. Please refer to the following concrete implementation.


public void add(Student student) throws StudentRequestException, StudentServiceException {

As we require these throw statements in multiple locations within the DAO and also in the interface, we will create an exception list at the class level.


private static final List<String> EXCEPTION_CLASS_NAMES = new ArrayList<>() {{
    add("RequestException");
    add("ServiceException");
}};

Next, we'll create the logic to generate the throw statements.


StringBuilder throwsExceptionString = new StringBuilder();
for (int i=0; i < EXCEPTION_CLASS_NAMES.size(); i++) {
    throwsExceptionString.append(this.className).append(EXCEPTION_CLASS_NAMES.get(i));
    if (i != EXCEPTION_CLASS_NAMES.size()-1) {
        throwsExceptionString.append(", ");
    }
}

Lastly, generate the complete line of the method declaration.


body.append("   public void add(").append(this.className).append(" ")
        .append(this.className.toLowerCase()).append(") throws ")
        .append(throwsExceptionString).append(" {
");

For the rest of the implementation, you can refer to the complete source code available here.

Writing the generated String to the correct file and location

There is a variable named processingEnv provided by the AbstractProcessor class. You can leverage it to obtain a reference variable of the Writer class for writing your string to the Java file.


try {
    Writer writer = processingEnv.getFiler()
            .createSourceFile(this.packageName + "." + this.className + "DaoImpl")
            .openWriter();
    writer.write(body.toString());
    writer.close();
} catch (IOException ioException) {
    ioException.printStackTrace();
}

Wrapping Up!

Congratulations, you've delved into the heart of creating a Code Generator Processor! In this tutorial, we explored generating a DAO class as an example, crafting dynamic parts of the code while using the StringBuilder to assemble the class structure. Our processor journey is nearly complete, and in the next post, we'll learn how to package your code for distribution using Maven. Additionally, we'll uncover the process of using the generated files to perform CRUD operations. Stay tuned for the final chapter of this tutorial series, and keep your coding spirit high!