Sqlc Nullable ENUM: Using Pointers Instead Of NullEnumType

by Felix Dubois 59 views

Hey everyone! Let's dive into a situation I encountered while working with sqlc, specifically version 1.29.0, and how it handles nullable ENUM columns. This can be a bit tricky, especially when you're aiming for that clean, pointer-based Go code. So, let's break it down, discuss the issue, and see how we can make things smoother.

The Issue: NullEnumType Instead of Pointers

So, the core problem arises when you're using emit_pointers_for_null_types with pgx/v5 in sqlc. The idea behind emit_pointers_for_null_types is fantastic: it allows sqlc to generate query functions that use *type instead of NullType, which keeps your code cleaner and more idiomatic Go. This works perfectly for regular table types, but things get a bit bumpy when dealing with ENUMs. And it looks like custom overridden types also face similar challenges. Let's illustrate with an example.

Consider this generated code snippet:

type SearchFoodParams struct {
 Food string
 FoodType NullFoodType
}

What we really want is:

type SearchFoodParams struct {
 Food string
 FoodType *FoodType
}

See the difference? We're aiming for a pointer to FoodType (*FoodType) instead of the NullFoodType. This is crucial for handling null values in a more Go-friendly way. It feels more natural and integrates better with the rest of your Go code. In essence, emit_pointers_for_null_types doesn't seem to extend its magic to ENUMs, which is a bit of a bummer. Whether this is a bug or a feature gap is debatable, but it definitely impacts how we work with nullable ENUMs. I personally lean towards this being a missing feature, as the expectation is that emit_pointers_for_null_types should cover all nullable types, including ENUMs. This consistency would make sqlc even more powerful and intuitive to use.

This distinction is important because NullFoodType is a struct with fields like Valid and FoodType, which adds extra boilerplate to your code when you just want to check for nil. Using a pointer *FoodType allows you to directly check for nil, making your code cleaner and easier to read. Moreover, using pointers aligns better with the Go convention for handling optional values. It's a more natural and efficient way to represent the absence of a value. The current behavior forces you to interact with the NullFoodType struct, which can feel cumbersome and less idiomatic.

Diving into the Details: Example Schema and Queries

Let's make this concrete with a real-world example. Imagine we're building a food database. We have a food_type ENUM and a foods table. Here's the SQL schema:

CREATE TYPE food_type AS ENUM ('sandwhich', 'salad', 'soup');

CREATE TABLE foods (
 id BIGSERIAL PRIMARY KEY,
 food text NOT NULL,
 food_type food_type -- NULL if food can't be classified. That should ofc never happen
);

We've got our food_type ENUM with values like 'sandwich', 'salad', and 'soup'. The foods table has an id, a food name, and a food_type. Notice the comment: NULL if food can't be classified. This highlights the need for handling null ENUM values. Now, let’s craft a simple SQL query to search for foods:

-- name: SearchFood :many
SELECT * FROM foods
WHERE food LIKE $1
AND food_type = $2;

This query, SearchFood, takes a food name pattern ($1) and a food_type ($2) as input. It fetches all foods that match the name pattern and have the specified food type. The crucial part here is the food_type = $2 condition. If we want to search for foods with a specific type or those with no type (NULL), we need to handle the food_type parameter correctly. The goal is to generate Go code that allows us to pass a *FoodType as the $2 parameter, making it easy to search for foods with a specific type or NULL if we pass nil. This is where the current behavior of sqlc falls short, as it generates code expecting a NullFoodType instead of *FoodType.

Configuration and Expected Output

Now, let’s look at the sqlc configuration:

version: "2"
sql:
 - schema: "schema.sql"
 queries: "query.sql"
 engine: "postgresql"
 gen:
 go:
 out: "db"
 sql_package: "pgx/v5"
 emit_pointers_for_null_types: true

The key here is emit_pointers_for_null_types: true. We're explicitly telling sqlc to use pointers for nullable types. This should ideally apply to our food_type ENUM as well. However, as we've seen, this doesn't happen automatically. The desired outcome is that the generated SearchFoodParams struct should look like this:

type SearchFoodParams struct {
 Food string
 FoodType *FoodType // We want this!
}

Instead, we get:

type SearchFoodParams struct {
 Food string
 FoodType NullFoodType // This is what we get
}

This discrepancy forces us to work with NullFoodType, which, as discussed earlier, is less idiomatic and adds unnecessary complexity. We have to deal with the Valid field and the underlying FoodType value, which makes the code less clean and harder to maintain. Imagine having multiple nullable ENUMs in your queries. The boilerplate code to handle these NullEnumType instances can quickly become overwhelming and detract from the overall readability of your application.

The Bigger Picture: Why This Matters

So, why is this such a big deal? Well, it's about writing clean, maintainable, and idiomatic Go code. When you're working on a large project, consistency is key. Having to handle ENUMs differently from other nullable types adds cognitive overhead and increases the chances of errors. Using pointers for nullable types is a well-established Go pattern. It's what developers expect, and it's what makes the code easy to understand and reason about. Deviating from this pattern for ENUMs creates unnecessary friction and makes the codebase less cohesive. Moreover, the extra boilerplate required to work with NullEnumType can significantly impact the readability of your code. Imagine having to check FoodType.Valid every time you want to determine if a food has a specific type. This quickly becomes tedious and makes the code harder to follow. By using pointers, you can simply check for nil, which is much cleaner and more intuitive. In the long run, addressing this issue will not only improve the developer experience but also lead to more robust and maintainable applications. Consistent handling of nullable types is crucial for building scalable and reliable systems.

Solution in Sight!

The good news is, I'm on it! I'm planning to submit a PR to address this issue. The goal is to extend the functionality of emit_pointers_for_null_types to include ENUMs. This will make working with nullable ENUMs in sqlc much smoother and more in line with Go best practices. The proposed solution will likely involve modifying the code generation logic in sqlc to correctly handle ENUM types when emit_pointers_for_null_types is enabled. This might require changes in how sqlc introspects the database schema and generates the corresponding Go code. The implementation should also consider the potential impact on existing codebases. It's important to ensure that the changes are backward-compatible and don't introduce any unexpected behavior. This might involve adding configuration options or providing clear migration paths for users who are currently relying on the NullEnumType behavior. Ultimately, the goal is to provide a seamless and intuitive experience for developers working with nullable ENUMs in sqlc. By generating code that uses pointers, we can simplify the code, improve readability, and align with Go's best practices.

Wrapping Up

So, to sum it up, the current behavior of sqlc with emit_pointers_for_null_types doesn't quite cover ENUMs as expected. We end up with NullEnumType instead of the cleaner *EnumType. This makes handling nullable ENUMs a bit more cumbersome than it needs to be. But don't worry, a fix is on the way! I believe that extending emit_pointers_for_null_types to include ENUMs will significantly improve the developer experience and make sqlc an even more powerful tool for building Go applications with databases. Stay tuned for the PR, and let's make sqlc even better together! Thanks for following along, and I hope this deep dive into nullable ENUMs in sqlc has been helpful. Remember, the goal is always to write clean, maintainable, and idiomatic code, and addressing these small inconsistencies can make a big difference in the long run.