Skip to content

Commit fdb07c3

Browse files
authored
Merge pull request #17 from sqlcj-dev/feature/postgresql-scalar-column-types
feat: add SERIAL, BIGSERIAL, UUID, and TIMESTAMP WITH TIME ZONE columns
2 parents af72584 + 3d5cc92 commit fdb07c3

9 files changed

Lines changed: 378 additions & 31 deletions

File tree

‎src/main/java/dev/sqlcj/generator/JavaCodeGenerator.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,9 @@ private String resolveImport(ColumnType type) {
127127
return switch (type) {
128128
case DATE -> "java.time.LocalDate";
129129
case TIMESTAMP -> "java.time.LocalDateTime";
130+
case TIMESTAMP_WITH_TIME_ZONE -> "java.time.OffsetDateTime";
130131
case DECIMAL -> "java.math.BigDecimal";
132+
case UUID -> "java.util.UUID";
131133
default -> null;
132134
};
133135
}

‎src/main/java/dev/sqlcj/generator/JavaNames.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ final class JavaNames {
3636
"BigDecimal",
3737
"LocalDate",
3838
"LocalDateTime",
39+
"OffsetDateTime",
40+
"UUID",
3941
"Boolean",
4042
"Integer",
4143
"Long",

‎src/main/java/dev/sqlcj/schema/ColumnType.java‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,7 @@ public enum ColumnType {
99
TEXT,
1010
DATE,
1111
TIMESTAMP,
12-
DECIMAL
12+
TIMESTAMP_WITH_TIME_ZONE,
13+
DECIMAL,
14+
UUID
1315
}

‎src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java‎

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@
1818
import java.util.ArrayList;
1919
import java.util.List;
2020
import java.util.Locale;
21+
import java.util.regex.Pattern;
2122

2223
public class DefaultSchemaParser implements SchemaParser {
2324

25+
/** One parenthesized type argument group, such as {@code (10, 2)}. */
26+
private static final Pattern TYPE_ARGUMENTS = Pattern.compile("\\([^)]*\\)");
27+
2428
@Override
2529
public Schema parse(String sql) {
2630
try {
@@ -66,14 +70,12 @@ private Table parseTable(CreateTable createTable) {
6670
}
6771

6872
private Column parseColumn(ColumnDefinition definition) {
69-
String name = columnName(definition);
70-
ColumnType type = parseColumnType(definition);
71-
boolean nullable = isNullable(definition);
73+
String typeName = typeName(definition);
7274

7375
return new Column(
74-
name,
75-
type,
76-
nullable
76+
columnName(definition),
77+
columnType(typeName),
78+
!isSerial(typeName) && isNullable(definition)
7779
);
7880
}
7981

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

87-
private ColumnType parseColumnType(ColumnDefinition definition) {
88-
String typeName = definition.getColDataType()
89+
/**
90+
* Returns the canonical spelling of a declared SQL type: upper case,
91+
* without type arguments such as a length or a precision, and with single
92+
* spaces between the remaining words.
93+
*/
94+
private String typeName(ColumnDefinition definition) {
95+
String dataType = definition.getColDataType()
8996
.getDataType()
9097
.toUpperCase(Locale.ROOT);
9198

92-
int parenthesisIndex = typeName.indexOf('(');
93-
94-
if (parenthesisIndex >= 0) {
95-
typeName = typeName.substring(0, parenthesisIndex).trim();
96-
}
99+
return TYPE_ARGUMENTS
100+
.matcher(dataType)
101+
.replaceAll(" ")
102+
.replaceAll("\\s+", " ")
103+
.trim();
104+
}
97105

106+
private ColumnType columnType(String typeName) {
98107
return switch (typeName) {
99-
case "INTEGER", "INT" -> ColumnType.INTEGER;
100-
case "BIGINT" -> ColumnType.BIGINT;
108+
case "INTEGER", "INT", "SERIAL" -> ColumnType.INTEGER;
109+
case "BIGINT", "BIGSERIAL" -> ColumnType.BIGINT;
101110
case "SMALLINT" -> ColumnType.SMALLINT;
102111
case "BOOLEAN", "BOOL" -> ColumnType.BOOLEAN;
103112
case "VARCHAR" -> ColumnType.VARCHAR;
104113
case "TEXT" -> ColumnType.TEXT;
105114
case "DATE" -> ColumnType.DATE;
106115
case "TIMESTAMP" -> ColumnType.TIMESTAMP;
116+
case "TIMESTAMP WITH TIME ZONE" -> ColumnType.TIMESTAMP_WITH_TIME_ZONE;
107117
case "DECIMAL", "NUMERIC" -> ColumnType.DECIMAL;
118+
case "UUID" -> ColumnType.UUID;
108119
default -> throw new UnsupportedOperationException(
109120
"Unsupported SQL column type: " + typeName
110121
);
111122
};
112123
}
113124

125+
/**
126+
* PostgreSQL defines the serial spellings as an integer type with a
127+
* sequence default and {@code NOT NULL}, so such a column is never
128+
* nullable.
129+
*/
130+
private boolean isSerial(String typeName) {
131+
return "SERIAL".equals(typeName) || "BIGSERIAL".equals(typeName);
132+
}
133+
114134
private boolean isNullable(ColumnDefinition definition) {
115135
List<String> specs = definition.getColumnSpecs();
116136

‎src/main/java/dev/sqlcj/type/DefaultTypeResolver.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ public String resolve(ColumnType type) {
1414
case VARCHAR, TEXT -> "String";
1515
case DATE -> "LocalDate";
1616
case TIMESTAMP -> "LocalDateTime";
17+
case TIMESTAMP_WITH_TIME_ZONE -> "OffsetDateTime";
1718
case DECIMAL -> "BigDecimal";
19+
case UUID -> "UUID";
1820
};
1921
}
2022
}

‎src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java‎

Lines changed: 114 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@
3131
import java.sql.Statement;
3232
import java.time.LocalDate;
3333
import java.time.LocalDateTime;
34+
import java.time.OffsetDateTime;
35+
import java.time.ZoneOffset;
3436
import java.util.ArrayList;
3537
import java.util.Arrays;
3638
import java.util.List;
39+
import java.util.UUID;
3740
import java.util.stream.Stream;
3841

3942
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -55,18 +58,35 @@ class PostgresIntegrationTest {
5558
private static final String SCHEMA = """
5659
CREATE TABLE users
5760
(
58-
id BIGINT PRIMARY KEY,
59-
code INTEGER NOT NULL,
60-
score SMALLINT,
61-
name VARCHAR(255),
62-
bio TEXT,
63-
active BOOLEAN,
64-
birth_date DATE,
65-
created_at TIMESTAMP,
66-
balance DECIMAL(10, 2)
61+
id BIGINT PRIMARY KEY,
62+
code INTEGER NOT NULL,
63+
score SMALLINT,
64+
name VARCHAR(255),
65+
bio TEXT,
66+
active BOOLEAN,
67+
birth_date DATE,
68+
created_at TIMESTAMP,
69+
balance DECIMAL(10, 2),
70+
serial_id SERIAL,
71+
revision BIGSERIAL,
72+
external_id UUID,
73+
updated_at TIMESTAMP(3) WITH TIME ZONE
6774
);
6875
""";
6976

77+
private static final UUID EXTERNAL_ID = UUID.fromString("3f2504e0-4f89-11d3-9a0c-0305e82c3301");
78+
79+
private static final OffsetDateTime UPDATED_AT = OffsetDateTime.of(
80+
2026,
81+
1,
82+
1,
83+
10,
84+
0,
85+
0,
86+
0,
87+
ZoneOffset.ofHours(3)
88+
);
89+
7090
private static PostgreSQLContainer<?> postgres;
7191

7292
private static DataSource dataSource;
@@ -291,11 +311,13 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception {
291311
Path classesDirectory = generateAndCompile("""
292312
-- name: InsertUser :exec
293313
INSERT INTO users
294-
(id, code, score, name, bio, active, birth_date, created_at, balance)
295-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);
314+
(id, code, score, name, bio, active, birth_date, created_at, balance,
315+
external_id, updated_at)
316+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
296317
297318
-- name: GetUser :one
298-
SELECT id, code, score, name, bio, active, birth_date, created_at, balance
319+
SELECT id, code, score, name, bio, active, birth_date, created_at, balance,
320+
external_id, updated_at
299321
FROM users
300322
WHERE id = $1;
301323
""");
@@ -313,7 +335,9 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception {
313335
Boolean.class,
314336
LocalDate.class,
315337
LocalDateTime.class,
316-
BigDecimal.class
338+
BigDecimal.class,
339+
UUID.class,
340+
OffsetDateTime.class
317341
);
318342

319343
Object affectedRows = insertMethod.invoke(
@@ -326,6 +350,8 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception {
326350
null,
327351
null,
328352
null,
353+
null,
354+
null,
329355
null
330356
);
331357

@@ -348,6 +374,81 @@ void shouldBindAndReadNullValuesThroughGeneratedCode() throws Exception {
348374
assertNull(component(result, "birth_date"));
349375
assertNull(component(result, "created_at"));
350376
assertNull(component(result, "balance"));
377+
assertNull(component(result, "external_id"));
378+
assertNull(component(result, "updated_at"));
379+
}
380+
}
381+
382+
/**
383+
* Covers the PostgreSQL round trip of the serial, UUID, and
384+
* timestamptz mappings: every value is written through the generated
385+
* write method and read back through the generated read method.
386+
*
387+
* <p>PostgreSQL normalizes a {@code timestamptz} to the session time zone,
388+
* so the read value is compared by instant rather than by offset.
389+
*/
390+
@Test
391+
void shouldRoundTripSerialUuidAndTimestampWithTimeZoneValues() throws Exception {
392+
Path classesDirectory = generateAndCompile("""
393+
-- name: InsertUser :exec
394+
INSERT INTO users (id, code, serial_id, revision, external_id, updated_at)
395+
VALUES ($1, $2, $3, $4, $5, $6);
396+
397+
-- name: GetUser :one
398+
SELECT id, serial_id, revision, external_id, updated_at
399+
FROM users
400+
WHERE id = $1;
401+
""");
402+
403+
try (URLClassLoader classLoader = classLoader(classesDirectory)) {
404+
Object insert = newQuery(classLoader, "InsertUser");
405+
406+
Method insertMethod = insert.getClass().getMethod(
407+
"insertUser",
408+
Long.class,
409+
Integer.class,
410+
Integer.class,
411+
Long.class,
412+
UUID.class,
413+
OffsetDateTime.class
414+
);
415+
416+
Object affectedRows = insertMethod.invoke(
417+
insert,
418+
7L,
419+
42,
420+
101,
421+
202L,
422+
EXTERNAL_ID,
423+
UPDATED_AT
424+
);
425+
426+
assertEquals(1, affectedRows);
427+
428+
Object query = newQuery(classLoader, "GetUser");
429+
430+
Method queryMethod = query.getClass().getMethod("getUser", Long.class);
431+
432+
Object result = queryMethod.invoke(query, 7L);
433+
434+
assertNotNull(result);
435+
436+
assertEquals(
437+
List.of("id", "serial_id", "revision", "external_id", "updated_at"),
438+
recordComponentNames(result)
439+
);
440+
441+
assertEquals(7L, component(result, "id"));
442+
assertEquals(101, component(result, "serial_id"));
443+
assertEquals(202L, component(result, "revision"));
444+
assertEquals(EXTERNAL_ID, component(result, "external_id"));
445+
446+
OffsetDateTime updatedAt = assertInstanceOf(
447+
OffsetDateTime.class,
448+
component(result, "updated_at")
449+
);
450+
451+
assertEquals(UPDATED_AT.toInstant(), updatedAt.toInstant());
351452
}
352453
}
353454

‎src/test/java/dev/sqlcj/generator/JavaCodeGeneratorNamingTest.java‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,28 @@ void shouldRenameClassConflictingWithImportedType() throws IOException {
102102
assertCompiles(file);
103103
}
104104

105+
@Test
106+
void shouldRenameClassConflictingWithImportedUuidType() throws IOException {
107+
GeneratedFile file = codeGenerator.generate(
108+
query(
109+
"UUID",
110+
List.of(new QueryColumn("external_id", ColumnType.UUID, true)),
111+
List.of()
112+
)
113+
);
114+
115+
String source = file.content();
116+
117+
assertEquals(Path.of("generated", "UUID_.java"), file.path());
118+
assertTrue(source.contains("import java.util.UUID;"));
119+
assertTrue(source.contains("public final class UUID_ {"));
120+
assertTrue(source.contains("public record UUID_Result("));
121+
assertTrue(source.contains("UUID external_id"));
122+
assertTrue(source.contains("resultSet.getObject(1, UUID.class)"));
123+
124+
assertCompiles(file);
125+
}
126+
105127
@Test
106128
void shouldReadRenamedResultComponentsByProjectionPosition() throws IOException {
107129
GeneratedFile file = codeGenerator.generate(

0 commit comments

Comments
 (0)