Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ private String resolveImport(ColumnType type) {
return switch (type) {
case DATE -> "java.time.LocalDate";
case TIMESTAMP -> "java.time.LocalDateTime";
case TIMESTAMP_WITH_TIME_ZONE -> "java.time.OffsetDateTime";
case DECIMAL -> "java.math.BigDecimal";
case UUID -> "java.util.UUID";
default -> null;
};
}
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/dev/sqlcj/generator/JavaNames.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ final class JavaNames {
"BigDecimal",
"LocalDate",
"LocalDateTime",
"OffsetDateTime",
"UUID",
"Boolean",
"Integer",
"Long",
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/dev/sqlcj/schema/ColumnType.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ public enum ColumnType {
TEXT,
DATE,
TIMESTAMP,
DECIMAL
TIMESTAMP_WITH_TIME_ZONE,
DECIMAL,
UUID
}
50 changes: 35 additions & 15 deletions src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;

public class DefaultSchemaParser implements SchemaParser {

/** One parenthesized type argument group, such as {@code (10, 2)}. */
private static final Pattern TYPE_ARGUMENTS = Pattern.compile("\\([^)]*\\)");

@Override
public Schema parse(String sql) {
try {
Expand Down Expand Up @@ -66,14 +70,12 @@ private Table parseTable(CreateTable createTable) {
}

private Column parseColumn(ColumnDefinition definition) {
String name = columnName(definition);
ColumnType type = parseColumnType(definition);
boolean nullable = isNullable(definition);
String typeName = typeName(definition);

return new Column(
name,
type,
nullable
columnName(definition),
columnType(typeName),
!isSerial(typeName) && isNullable(definition)
);
}

Expand All @@ -84,33 +86,51 @@ private String columnName(ColumnDefinition definition) {
return MultiPartName.unquote(definition.getColumnName());
}

private ColumnType parseColumnType(ColumnDefinition definition) {
String typeName = definition.getColDataType()
/**
* Returns the canonical spelling of a declared SQL type: upper case,
* without type arguments such as a length or a precision, and with single
* spaces between the remaining words.
*/
private String typeName(ColumnDefinition definition) {
String dataType = definition.getColDataType()
.getDataType()
.toUpperCase(Locale.ROOT);

int parenthesisIndex = typeName.indexOf('(');

if (parenthesisIndex >= 0) {
typeName = typeName.substring(0, parenthesisIndex).trim();
}
return TYPE_ARGUMENTS
.matcher(dataType)
.replaceAll(" ")
.replaceAll("\\s+", " ")
.trim();
}

private ColumnType columnType(String typeName) {
return switch (typeName) {
case "INTEGER", "INT" -> ColumnType.INTEGER;
case "BIGINT" -> ColumnType.BIGINT;
case "INTEGER", "INT", "SERIAL" -> ColumnType.INTEGER;
case "BIGINT", "BIGSERIAL" -> ColumnType.BIGINT;
case "SMALLINT" -> ColumnType.SMALLINT;
case "BOOLEAN", "BOOL" -> ColumnType.BOOLEAN;
case "VARCHAR" -> ColumnType.VARCHAR;
case "TEXT" -> ColumnType.TEXT;
case "DATE" -> ColumnType.DATE;
case "TIMESTAMP" -> ColumnType.TIMESTAMP;
case "TIMESTAMP WITH TIME ZONE" -> ColumnType.TIMESTAMP_WITH_TIME_ZONE;
case "DECIMAL", "NUMERIC" -> ColumnType.DECIMAL;
case "UUID" -> ColumnType.UUID;
default -> throw new UnsupportedOperationException(
"Unsupported SQL column type: " + typeName
);
};
}

/**
* PostgreSQL defines the serial spellings as an integer type with a
* sequence default and {@code NOT NULL}, so such a column is never
* nullable.
*/
private boolean isSerial(String typeName) {
return "SERIAL".equals(typeName) || "BIGSERIAL".equals(typeName);
}

private boolean isNullable(ColumnDefinition definition) {
List<String> specs = definition.getColumnSpecs();

Expand Down
2 changes: 2 additions & 0 deletions src/main/java/dev/sqlcj/type/DefaultTypeResolver.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ public String resolve(ColumnType type) {
case VARCHAR, TEXT -> "String";
case DATE -> "LocalDate";
case TIMESTAMP -> "LocalDateTime";
case TIMESTAMP_WITH_TIME_ZONE -> "OffsetDateTime";
case DECIMAL -> "BigDecimal";
case UUID -> "UUID";
};
}
}
127 changes: 114 additions & 13 deletions src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@
import java.sql.Statement;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.assertEquals;
Expand All @@ -55,18 +58,35 @@ class PostgresIntegrationTest {
private static final String SCHEMA = """
CREATE TABLE users
(
id BIGINT PRIMARY KEY,
code INTEGER NOT NULL,
score SMALLINT,
name VARCHAR(255),
bio TEXT,
active BOOLEAN,
birth_date DATE,
created_at TIMESTAMP,
balance DECIMAL(10, 2)
id BIGINT PRIMARY KEY,
code INTEGER NOT NULL,
score SMALLINT,
name VARCHAR(255),
bio TEXT,
active BOOLEAN,
birth_date DATE,
created_at TIMESTAMP,
balance DECIMAL(10, 2),
serial_id SERIAL,
revision BIGSERIAL,
external_id UUID,
updated_at TIMESTAMP(3) WITH TIME ZONE
);
""";

private static final UUID EXTERNAL_ID = UUID.fromString("3f2504e0-4f89-11d3-9a0c-0305e82c3301");

private static final OffsetDateTime UPDATED_AT = OffsetDateTime.of(
2026,
1,
1,
10,
0,
0,
0,
ZoneOffset.ofHours(3)
);

private static PostgreSQLContainer<?> postgres;

private static DataSource dataSource;
Expand Down Expand Up @@ -291,11 +311,13 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception {
Path classesDirectory = generateAndCompile("""
-- name: InsertUser :exec
INSERT INTO users
(id, code, score, name, bio, active, birth_date, created_at, balance)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);
(id, code, score, name, bio, active, birth_date, created_at, balance,
external_id, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);

-- name: GetUser :one
SELECT id, code, score, name, bio, active, birth_date, created_at, balance
SELECT id, code, score, name, bio, active, birth_date, created_at, balance,
external_id, updated_at
FROM users
WHERE id = $1;
""");
Expand All @@ -313,7 +335,9 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception {
Boolean.class,
LocalDate.class,
LocalDateTime.class,
BigDecimal.class
BigDecimal.class,
UUID.class,
OffsetDateTime.class
);

Object affectedRows = insertMethod.invoke(
Expand All @@ -326,6 +350,8 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception {
null,
null,
null,
null,
null,
null
);

Expand All @@ -348,6 +374,81 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception {
assertNull(component(result, "birth_date"));
assertNull(component(result, "created_at"));
assertNull(component(result, "balance"));
assertNull(component(result, "external_id"));
assertNull(component(result, "updated_at"));
}
}

/**
* Covers the PostgreSQL round trip of the serial, UUID, and
* timestamptz mappings: every value is written through the generated
* write method and read back through the generated read method.
*
* <p>PostgreSQL normalizes a {@code timestamptz} to the session time zone,
* so the read value is compared by instant rather than by offset.
*/
@Test
void shouldRoundTripSerialUuidAndTimestampWithTimeZoneValues() throws Exception {
Path classesDirectory = generateAndCompile("""
-- name: InsertUser :exec
INSERT INTO users (id, code, serial_id, revision, external_id, updated_at)
VALUES ($1, $2, $3, $4, $5, $6);

-- name: GetUser :one
SELECT id, serial_id, revision, external_id, updated_at
FROM users
WHERE id = $1;
""");

try (URLClassLoader classLoader = classLoader(classesDirectory)) {
Object insert = newQuery(classLoader, "InsertUser");

Method insertMethod = insert.getClass().getMethod(
"insertUser",
Long.class,
Integer.class,
Integer.class,
Long.class,
UUID.class,
OffsetDateTime.class
);

Object affectedRows = insertMethod.invoke(
insert,
7L,
42,
101,
202L,
EXTERNAL_ID,
UPDATED_AT
);

assertEquals(1, affectedRows);

Object query = newQuery(classLoader, "GetUser");

Method queryMethod = query.getClass().getMethod("getUser", Long.class);

Object result = queryMethod.invoke(query, 7L);

assertNotNull(result);

assertEquals(
List.of("id", "serial_id", "revision", "external_id", "updated_at"),
recordComponentNames(result)
);

assertEquals(7L, component(result, "id"));
assertEquals(101, component(result, "serial_id"));
assertEquals(202L, component(result, "revision"));
assertEquals(EXTERNAL_ID, component(result, "external_id"));

OffsetDateTime updatedAt = assertInstanceOf(
OffsetDateTime.class,
component(result, "updated_at")
);

assertEquals(UPDATED_AT.toInstant(), updatedAt.toInstant());
}
}

Expand Down
22 changes: 22 additions & 0 deletions src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,28 @@ void shouldRenameClassConflictingWithImportedType() throws IOException {
assertCompiles(file);
}

@Test
void shouldRenameClassConflictingWithImportedUuidType() throws IOException {
GeneratedFile file = codeGenerator.generate(
query(
"UUID",
List.of(new QueryColumn("external_id", ColumnType.UUID, true)),
List.of()
)
);

String source = file.content();

assertEquals(Path.of("generated", "UUID_.java"), file.path());
assertTrue(source.contains("import java.util.UUID;"));
assertTrue(source.contains("public final class UUID_ {"));
assertTrue(source.contains("public record UUID_Result("));
assertTrue(source.contains("UUID external_id"));
assertTrue(source.contains("resultSet.getObject(1, UUID.class)"));

assertCompiles(file);
}

@Test
void shouldReadRenamedResultComponentsByProjectionPosition() throws IOException {
GeneratedFile file = codeGenerator.generate(
Expand Down
Loading
Loading