diff --git a/docs/configuration.md b/docs/configuration.md index a7f5461..918a595 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -56,7 +56,7 @@ java: | `version` | string | Configuration contract version. Must be `"1"`. | | `sql` | list | Non-empty, ordered list of source entries. | | `sql[].name` | string | Identity of the query group. Names the generated repository. | -| `sql[].schema` | string | Path to a file containing `CREATE TABLE` statements. | +| `sql[].schema` | string or list | One path, or a non-empty ordered list of paths, to a file containing `CREATE TABLE` statements or to a directory of `.sql` migration files. | | `sql[].queries` | string | Path to a file containing named queries. | | `java` | mapping | Java generation settings. | | `java.package` | string | Package of the generated Java classes. | @@ -70,7 +70,7 @@ against that entry's own schema, and query order inside a query file is preserved. Schemas are not shared or merged between entries. A query can only use tables -declared in the schema file of its own entry. +declared in the schema sources of its own entry. One entry generates exactly one repository containing every query of its query source, in declared query order. Query names are therefore scoped to their @@ -97,6 +97,64 @@ sqlcj: Invalid configuration in /home/dev/project/sqlcj.yaml: 'sql[0].name' valu sqlcj does not derive the name from a table or a file name. A query file may join or write several tables, so the group boundary is declared, not guessed. +### `sql[].schema` + +`sql[].schema` is either one path or a non-empty ordered list of paths: + +```yaml +sql: + - name: Author + schema: sql/authors/schema.sql + queries: sql/authors/queries.sql + - name: Order + schema: + - sql/orders/baseline.sql + - sql/orders/migrations + queries: sql/orders/queries.sql +``` + +A listed path is either a file, which contributes itself, or a directory, which +contributes the regular files in it whose name ends in `.sql`. A directory is +read non-recursively, and the name match is case-sensitive: a subdirectory and a +file named anything else, including `schema.SQL`, are ignored. The listed paths +keep their declared order and each directory is expanded in place. + +A directory is ordered like a Flyway migration directory: + +1. A versioned migration file, named `V__.sql` with + version parts separated by `.` or `_`, comes first. Two versions are compared + part by part as numbers, counting a missing trailing part as zero, so + `V1__init.sql`, `V1_1__add_index.sql`, `V2__add_orders.sql`, and + `V10__add_totals.sql` are ordered exactly that way. +2. Every other `.sql` file follows, ordered by file name. +3. An undo file, named `U__.sql`, is ignored. + +The order never depends on how the filesystem lists the directory. sqlcj only +reads the files, so a Flyway placeholder, a configured file-name prefix, and a +repeatable migration have no meaning of their own. + +Each schema file is parsed on its own, and the tables of the entry follow file +order, so a schema failure names the file that contains it: + +```text +sqlcj: Invalid schema source /home/dev/project/sql/orders/migrations/V2__add_orders.sql: Unsupported schema statement: Alter +``` + +A directory that contributes no file, and two versioned files of one directory +that declare the same version, are invalid schema input: + +```text +sqlcj: Invalid schema source /home/dev/project/sql/orders/migrations: directory contains no .sql files +sqlcj: Invalid schema source /home/dev/project/sql/orders/migrations: duplicate migration version in V1_0__add_index.sql and V1__init.sql +``` + +A configured path that cannot be read, whether it is a file or a directory, +ends the run the same way: + +```text +sqlcj: Cannot read schema source: /home/dev/project/sql/orders/migrations: Permission denied +``` + ### `java.package` `java.package` must be a dot-separated sequence of valid, non-keyword Java @@ -115,14 +173,14 @@ generated/dev/example/generated/AuthorRepository.java - String values must not be blank. - Unknown fields are rejected. - Wrong-typed fields are rejected, including an unquoted numeric `version`. -- A missing or unsupported `version`, a missing section, an empty `sql` list, a - null `sql` entry, a blank value, an invalid `sql[].name`, and an invalid - `java.package` are all invalid configuration. +- A missing or unsupported `version`, a missing section, an empty `sql` list, an + empty `sql[].schema` list, a null `sql` entry, a blank value, an invalid + `sql[].name`, and an invalid `java.package` are all invalid configuration. ## Path Resolution -A relative `schema`, `queries`, or `java.out` path is resolved against the -directory that contains the configuration file, whether that file is the +A relative `queries`, `java.out`, or listed `schema` path is resolved against +the directory that contains the configuration file, whether that file is the default `sqlcj.yaml` or one named by `--config`, and not against the process working directory at a later point in time. An absolute path is used as-is. diff --git a/docs/postgresql.md b/docs/postgresql.md index 2fba9a0..cbc52d8 100644 --- a/docs/postgresql.md +++ b/docs/postgresql.md @@ -87,12 +87,15 @@ test-scoped dependencies of the build. ## Schema Snapshot Input -A `sql[].schema` file is an explicit snapshot of the tables a query may use. +`sql[].schema` is an explicit snapshot of the tables a query may use. It is one +file, a list of files, or a directory of `.sql` migration files; see +[`sql[].schema`](configuration.md#sqlschema) for the accepted forms and the +order the files are read in. -- Only `CREATE TABLE` statements are accepted. Any other statement in the file - is rejected. +- Only `CREATE TABLE` statements are accepted. Any other statement in a schema + file is rejected. - A file may contain several `CREATE TABLE` statements, and table order is - preserved. + preserved across the schema files of one entry. - SQL identifier delimiters are removed for the parsed model, so the table `"user data"` is modeled as `user data` and the column `"user id"` is modeled as `user id`. diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/DefaultSourceLoader.java b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/DefaultSourceLoader.java index 907ab16..f62d0ba 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/DefaultSourceLoader.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/DefaultSourceLoader.java @@ -10,12 +10,29 @@ import dev.sqlcj.parser.QueryParser; import java.io.IOException; +import java.math.BigInteger; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; public final class DefaultSourceLoader implements SourceLoader { + private static final String SQL_SUFFIX = ".sql"; + + /** A Flyway versioned migration file, such as {@code V1_1__add_index.sql}. */ + private static final Pattern VERSIONED = Pattern.compile("V(\\d+([._]\\d+)*)__.*\\.sql"); + + /** A Flyway undo migration file, such as {@code U1_1__add_index.sql}. */ + private static final Pattern UNDO = Pattern.compile("U(\\d+([._]\\d+)*)__.*\\.sql"); + + /** One part of a migration version, separated by {@code .} or {@code _}. */ + private static final Pattern VERSION_SEPARATOR = Pattern.compile("[._]"); + private final FileLoader fileLoader = new DefaultFileLoader(); private final QueryParser queryParser = new DefaultQueryParser(); @@ -29,17 +46,15 @@ public List load(Config config) { List sources = new ArrayList<>(); for (SqlConfig sqlConfig : config.sql()) { - Path schemaPath = Path.of(sqlConfig.schema()); Path queriesPath = Path.of(sqlConfig.queries()); - String schema = read(schemaPath, "schema"); + List schemaFiles = loadSchema(sqlConfig.schema()); List queries = parse(read(queriesPath, "queries"), queriesPath); sources.add( new Source( sqlConfig.name(), - schemaPath, - schema, + schemaFiles, queriesPath, queries ) @@ -49,6 +64,130 @@ public List load(Config config) { return sources; } + /** + * Loads the schema files of one entry in the order they apply: the + * configured paths in declared order, each directory expanded in place into + * its own migration files. + */ + private List loadSchema(List configuredPaths) { + List schemaFiles = new ArrayList<>(); + + for (String configuredPath : configuredPaths) { + Path path = Path.of(configuredPath); + + if (Files.isDirectory(path)) { + for (Path file : migrationFiles(path)) { + schemaFiles.add(new SchemaFile(file, read(file, "schema"))); + } + } else { + schemaFiles.add(new SchemaFile(path, read(path, "schema"))); + } + } + + return List.copyOf(schemaFiles); + } + + /** + * The {@code .sql} files one directory contributes, in Flyway migration + * order: versioned files by version, then every other file by name. The + * order never depends on how the filesystem lists the directory. + */ + private List migrationFiles(Path directory) { + List candidates = list(directory).stream() + .filter(Files::isRegularFile) + .filter(path -> fileName(path).endsWith(SQL_SUFFIX)) + .filter(path -> !UNDO.matcher(fileName(path)).matches()) + .sorted(Comparator.comparing(this::fileName)) + .toList(); + + if (candidates.isEmpty()) { + throw new CompilationException( + "Invalid schema source %s: directory contains no .sql files" + .formatted(directory) + ); + } + + List versioned = candidates.stream() + .filter(path -> version(path) != null) + .sorted(Comparator.comparing(this::version, this::compareVersions)) + .toList(); + + checkDuplicateVersions(directory, versioned); + + List files = new ArrayList<>(versioned); + + candidates.stream() + .filter(path -> version(path) == null) + .forEach(files::add); + + return files; + } + + private List list(Path directory) { + try (Stream entries = Files.list(directory)) { + return entries.toList(); + } catch (IOException e) { + throw new CompilationException( + "Cannot read schema source: %s: %s" + .formatted(directory, FileSystemReason.of(e)), + e + ); + } + } + + /** The declared version of a versioned migration file, or {@code null}. */ + private String version(Path path) { + Matcher matcher = VERSIONED.matcher(fileName(path)); + + return matcher.matches() ? matcher.group(1) : null; + } + + /** + * Compares two migration versions part by part as numbers, counting a + * missing trailing part as zero, so that {@code 1} precedes {@code 1.1} and + * {@code 2} precedes {@code 10}. + */ + private int compareVersions(String left, String right) { + String[] leftParts = VERSION_SEPARATOR.split(left); + String[] rightParts = VERSION_SEPARATOR.split(right); + + for (int i = 0; i < Math.max(leftParts.length, rightParts.length); i++) { + int comparison = part(leftParts, i).compareTo(part(rightParts, i)); + + if (comparison != 0) { + return comparison; + } + } + + return 0; + } + + private BigInteger part(String[] parts, int index) { + return index < parts.length ? new BigInteger(parts[index]) : BigInteger.ZERO; + } + + /** + * Rejects two versioned files of one directory that declare the same + * version, because neither order of the two is the applied order. + */ + private void checkDuplicateVersions(Path directory, List versioned) { + for (int i = 1; i < versioned.size(); i++) { + Path previous = versioned.get(i - 1); + Path current = versioned.get(i); + + if (compareVersions(version(previous), version(current)) == 0) { + throw new CompilationException( + "Invalid schema source %s: duplicate migration version in %s and %s" + .formatted(directory, fileName(previous), fileName(current)) + ); + } + } + } + + private String fileName(Path path) { + return path.getFileName().toString(); + } + private String read(Path path, String kind) { try { return fileLoader.read(path); diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SchemaFile.java b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SchemaFile.java new file mode 100644 index 0000000..a3959d2 --- /dev/null +++ b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SchemaFile.java @@ -0,0 +1,13 @@ +package dev.sqlcj.compiler; + +import java.nio.file.Path; + +/** + * One loaded schema file and the path it was loaded from, which identifies it + * in a compilation diagnostic. + */ +public record SchemaFile( + Path path, + String sql +) { +} diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/Source.java b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/Source.java index bc05da6..8634152 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/Source.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/Source.java @@ -10,11 +10,11 @@ * identify a source in a compilation diagnostic. * * @param name the configured group identity that names the generated repository + * @param schemaFiles the schema files of the entry, in the order they apply */ public record Source( String name, - Path schemaPath, - String schema, + List schemaFiles, Path queriesPath, List queries ) { diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java index b344008..8bf28fb 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java @@ -11,6 +11,7 @@ import dev.sqlcj.io.GeneratedOutputManifest; import dev.sqlcj.parser.Query; import dev.sqlcj.schema.Schema; +import dev.sqlcj.schema.Table; import dev.sqlcj.schema.parser.DefaultSchemaParser; import dev.sqlcj.schema.parser.SchemaParser; import dev.sqlcj.sql.ParsedSql; @@ -89,12 +90,26 @@ private List generate(List sources, CodeGenerator codeGen return files; } + /** + * Parses the schema files of one entry in order into that entry's schema, + * so a table keeps the position of the file that declares it. + */ private Schema parseSchema(Source source) { + List tables = new ArrayList<>(); + + for (SchemaFile schemaFile : source.schemaFiles()) { + tables.addAll(parseSchemaFile(schemaFile).tables()); + } + + return new Schema(tables); + } + + private Schema parseSchemaFile(SchemaFile schemaFile) { try { - return schemaParser.parse(source.schema()); + return schemaParser.parse(schemaFile.sql()); } catch (RuntimeException e) { throw new CompilationException( - "Invalid schema source %s: %s".formatted(source.schemaPath(), reason(e)), + "Invalid schema source %s: %s".formatted(schemaFile.path(), reason(e)), e ); } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/config/ConfigValidator.java b/sqlcj-cli/src/main/java/dev/sqlcj/config/ConfigValidator.java index 46e0a6b..08df996 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/config/ConfigValidator.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/config/ConfigValidator.java @@ -72,11 +72,41 @@ private void validateSql(List sql, Path configFile) { validateName(entry.name(), i, configFile); - requireValue(entry.schema(), "sql[%d].schema".formatted(i), configFile); + validateSchema(entry.schema(), i, configFile); requireValue(entry.queries(), "sql[%d].queries".formatted(i), configFile); } } + /** + * Requires at least one schema path. A single configured path is reported + * as the field itself, so the single-path form keeps its diagnostics, while + * one path of a longer list is reported with its index. + */ + private void validateSchema(List schema, int index, Path configFile) { + String field = "sql[%d].schema".formatted(index); + + if (schema == null) { + throw invalid(configFile, "'%s' is required".formatted(field)); + } + + if (schema.isEmpty()) { + throw invalid( + configFile, + "'%s' must contain at least one path".formatted(field) + ); + } + + if (schema.size() == 1) { + requireValue(schema.getFirst(), field, configFile); + + return; + } + + for (int i = 0; i < schema.size(); i++) { + requireValue(schema.get(i), "%s[%d]".formatted(field, i), configFile); + } + } + /** * Requires a group name that can be used unchanged as the prefix of the * generated repository type name. diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/config/SqlConfig.java b/sqlcj-cli/src/main/java/dev/sqlcj/config/SqlConfig.java index 2f55625..3e420c4 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/config/SqlConfig.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/config/SqlConfig.java @@ -1,13 +1,25 @@ package dev.sqlcj.config; +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.util.List; + /** * One configured query group. * * @param name the required group identity that names the generated repository + * @param schema the ordered schema sources of the group, each a file or a + * directory of migration files */ public record SqlConfig( String name, - String schema, + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + List schema, String queries ) { + + /** One configured schema source, the single-path form of the field. */ + public SqlConfig(String name, String schema, String queries) { + this(name, schema == null ? null : List.of(schema), queries); + } } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/config/YamlConfigLoader.java b/sqlcj-cli/src/main/java/dev/sqlcj/config/YamlConfigLoader.java index 1e71b9d..2c05b49 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/config/YamlConfigLoader.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/config/YamlConfigLoader.java @@ -246,7 +246,7 @@ private Config resolvePaths(Config config, Path baseDirectory) { .map( entry -> new SqlConfig( entry.name(), - resolvePath(entry.schema(), baseDirectory), + resolvePaths(entry.schema(), baseDirectory), resolvePath(entry.queries(), baseDirectory) ) ) @@ -260,6 +260,12 @@ private Config resolvePaths(Config config, Path baseDirectory) { return new Config(config.version(), sql, java); } + private List resolvePaths(List values, Path baseDirectory) { + return values.stream() + .map(value -> resolvePath(value, baseDirectory)) + .toList(); + } + private String resolvePath(String value, Path baseDirectory) { Path path = Path.of(value); diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/DefaultSourceLoaderTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/DefaultSourceLoaderTest.java index a6832ae..871b987 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/DefaultSourceLoaderTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/DefaultSourceLoaderTest.java @@ -10,11 +10,13 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; class DefaultSourceLoaderTest { @@ -66,8 +68,8 @@ void shouldLoadEntriesInDeclaredOrder() throws IOException { assertEquals(2, sources.size()); - assertTrue(sources.getFirst().schema().contains("CREATE TABLE users")); - assertTrue(sources.get(1).schema().contains("CREATE TABLE orders")); + assertTrue(schemaOf(sources.getFirst()).contains("CREATE TABLE users")); + assertTrue(schemaOf(sources.get(1)).contains("CREATE TABLE orders")); assertEquals( List.of("GetUser", "ListUsers"), @@ -252,12 +254,193 @@ void shouldCarrySourcePathsAndQueryLines() throws IOException { Source source = sources.getFirst(); assertEquals("Users", source.name()); - assertEquals(schema, source.schemaPath()); + assertEquals(List.of(schema), schemaPaths(source)); assertEquals(queries, source.queriesPath()); assertEquals(1, source.queries().getFirst().line()); assertEquals(6, source.queries().get(1).line()); } + @Test + void shouldLoadListedFilesAndDirectoryMigrationsInOrder() throws IOException { + Path prelude = write("prelude.sql", table("accounts")); + + Path migrations = directory("migrations"); + + writeIn(migrations, "V10__ten.sql", table("ten")); + writeIn(migrations, "V2__two.sql", table("two")); + writeIn(migrations, "V1_1__one_one.sql", table("one_one")); + writeIn(migrations, "V1__one.sql", table("one")); + writeIn(migrations, "baseline.sql", table("baseline")); + writeIn(migrations, "a_extra.sql", table("a_extra")); + writeIn(migrations, "U1__undo.sql", table("undo")); + writeIn(migrations, "V3__upper.SQL", table("upper")); + writeIn(migrations, "notes.txt", "not sql"); + + Path nested = directory("migrations/nested.sql"); + + writeIn(nested, "V4__nested.sql", table("nested")); + + Path queries = write("queries.sql", """ + -- name: GetUser :one + SELECT id + FROM users + WHERE id = $1; + """); + + List sources = sourceLoader.load( + config( + new SqlConfig( + "Users", + List.of(prelude.toString(), migrations.toString()), + queries.toString() + ) + ) + ); + + Source source = sources.getFirst(); + + assertEquals( + List.of( + prelude, + migrations.resolve("V1__one.sql"), + migrations.resolve("V1_1__one_one.sql"), + migrations.resolve("V2__two.sql"), + migrations.resolve("V10__ten.sql"), + migrations.resolve("a_extra.sql"), + migrations.resolve("baseline.sql") + ), + schemaPaths(source) + ); + + assertEquals(table("accounts"), schemaOf(source)); + assertEquals(table("one"), source.schemaFiles().get(1).sql()); + } + + @Test + void shouldRejectDuplicateMigrationVersionsInDirectory() throws IOException { + Path migrations = directory("migrations"); + + writeIn(migrations, "V1__a.sql", table("a")); + writeIn(migrations, "V1_0__b.sql", table("b")); + + Path queries = write("queries.sql", """ + -- name: GetUser :one + SELECT id + FROM users + WHERE id = $1; + """); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> sourceLoader.load( + config( + new SqlConfig( + "Users", + migrations.toString(), + queries.toString() + ) + ) + ) + ); + + assertEquals( + "Invalid schema source %s: duplicate migration version in V1_0__b.sql and V1__a.sql" + .formatted(migrations), + exception.getMessage() + ); + } + + @Test + void shouldRejectSchemaDirectoryWithoutSqlFiles() throws IOException { + Path migrations = directory("migrations"); + + writeIn(migrations, "U1__undo.sql", table("undo")); + writeIn(migrations, "notes.txt", "not sql"); + + Path queries = write("queries.sql", """ + -- name: GetUser :one + SELECT id + FROM users + WHERE id = $1; + """); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> sourceLoader.load( + config( + new SqlConfig( + "Users", + migrations.toString(), + queries.toString() + ) + ) + ) + ); + + assertEquals( + "Invalid schema source %s: directory contains no .sql files" + .formatted(migrations), + exception.getMessage() + ); + } + + @Test + void shouldRejectUnlistableSchemaDirectory() throws IOException { + Path migrations = directory("migrations"); + + writeIn(migrations, "V1__one.sql", table("one")); + + Path queries = write("queries.sql", """ + -- name: GetUser :one + SELECT id + FROM users + WHERE id = $1; + """); + + Files.setPosixFilePermissions( + migrations, + PosixFilePermissions.fromString("-wx------") + ); + + try { + assumeFalse( + Files.isReadable(migrations), + "requires a filesystem that enforces directory permissions" + ); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> sourceLoader.load( + config( + new SqlConfig( + "Users", + migrations.toString(), + queries.toString() + ) + ) + ) + ); + + assertEquals( + "Cannot read schema source: %s: Permission denied".formatted(migrations), + exception.getMessage() + ); + } finally { + Files.setPosixFilePermissions( + migrations, + PosixFilePermissions.fromString("rwx------") + ); + } + } + + private String schemaOf(Source source) { + return source.schemaFiles().getFirst().sql(); + } + + private List schemaPaths(Source source) { + return source.schemaFiles().stream().map(SchemaFile::path).toList(); + } + private List names(Source source) { return source.queries().stream().map(Query::name).toList(); } @@ -272,6 +455,25 @@ private Config config(SqlConfig... entries) { ); } + private String table(String name) { + return """ + CREATE TABLE %s + ( + id BIGINT NOT NULL + ); + """.formatted(name); + } + + private Path directory(String name) throws IOException { + return Files.createDirectories(tempDir.resolve(name)); + } + + private void writeIn(Path directory, String name, String content) throws IOException { + Path file = directory.resolve(name); + + Files.writeString(file, content); + } + private Path write(String name, String content) throws IOException { Path file = tempDir.resolve(name); diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java index 36fa41f..cf0950a 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -2170,6 +2170,181 @@ name VARCHAR(255), ); } + /** + * A migration directory and the single snapshot of the same tables describe + * one schema, so they generate the same repository source. + */ + @Test + void shouldGenerateTheSameRepositoryFromAMigrationDirectoryAndASnapshot() + throws IOException { + String users = """ + CREATE TABLE users + ( + id BIGINT NOT NULL, + name VARCHAR(255) + ); + """; + + String orders = """ + CREATE TABLE orders + ( + id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + total DECIMAL(10, 2) + ); + """; + + Path migrations = Files.createDirectories(tempDir.resolve("migrations")); + + Files.writeString(migrations.resolve("V2__orders.sql"), orders); + Files.writeString(migrations.resolve("V1__users.sql"), users); + + Path snapshotFile = tempDir.resolve("schema.sql"); + + Files.writeString(snapshotFile, users + "\n" + orders); + + Path queriesFile = tempDir.resolve("queries.sql"); + + Files.writeString( + queriesFile, + """ + -- name: ListUsers :many + SELECT id, name + FROM users; + + -- name: ListOrders :many + SELECT * + FROM orders + WHERE user_id = $1; + """ + ); + + Path fromDirectory = generateUsersRepository( + List.of(migrations.toString()), + queriesFile, + tempDir.resolve("generated-directory") + ); + + Path fromSnapshot = generateUsersRepository( + List.of(snapshotFile.toString()), + queriesFile, + tempDir.resolve("generated-snapshot") + ); + + assertArrayEquals( + Files.readAllBytes(fromSnapshot), + Files.readAllBytes(fromDirectory) + ); + + Path classesDirectory = tempDir.resolve("classes"); + + Files.createDirectories(classesDirectory); + + JavaCompiler compilerApi = ToolProvider.getSystemJavaCompiler(); + + assertNotNull(compilerApi); + + assertEquals( + 0, + compilerApi.run( + null, + null, + null, + "-classpath", + System.getProperty("java.class.path"), + "-d", + classesDirectory.toString(), + fromDirectory.toString() + ) + ); + } + + /** A schema failure names the migration file that contains it. */ + @Test + void shouldReportTheMigrationFileThatFailsToParse() throws IOException { + Path migrations = Files.createDirectories(tempDir.resolve("migrations")); + + Files.writeString( + migrations.resolve("V1__users.sql"), + """ + CREATE TABLE users + ( + id BIGINT NOT NULL + ); + """ + ); + + Path broken = migrations.resolve("V2__orders.sql"); + + Files.writeString( + broken, + """ + CREATE TABLE orders ( + id BIGINT NOT NULL, + user_id BIGINT NOT NULL + ; + """ + ); + + Path queriesFile = tempDir.resolve("queries.sql"); + + Files.writeString( + queriesFile, + """ + -- name: ListUsers :many + SELECT id + FROM users; + """ + ); + + Path generatedDirectory = tempDir.resolve("generated"); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> generateUsersRepository( + List.of(migrations.toString()), + queriesFile, + generatedDirectory + ) + ); + + assertEquals( + "Invalid schema source %s: ".formatted(broken) + + "Encountered unexpected token: \";\" " + + "at line 4, column 1", + exception.getMessage() + ); + + assertFalse(Files.exists(generatedDirectory)); + } + + /** Generates the one repository of the {@code Users} group. */ + private Path generateUsersRepository( + List schema, + Path queriesFile, + Path generatedDirectory + ) { + Config config = new Config( + List.of( + new SqlConfig( + "Users", + schema, + queriesFile.toString() + ) + ), + new JavaConfig( + generatedDirectory.toString(), + "generated" + ) + ); + + new SqlcjCompiler().compile(config); + + return generatedDirectory + .resolve("generated") + .resolve("UsersRepository.java"); + } + /** Generates and compiles the one repository of the {@code Users} group. */ private Path generateAndCompile(String schema, String queries) throws IOException { Path schemaFile = tempDir.resolve("schema.sql"); diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/config/YamlConfigLoaderTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/config/YamlConfigLoaderTest.java index 025b3a1..ff112db 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/config/YamlConfigLoaderTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/config/YamlConfigLoaderTest.java @@ -39,7 +39,7 @@ void shouldMapVersionOneFields() throws IOException { assertEquals(1, config.sql().size()); assertEquals("Users", config.sql().getFirst().name()); assertEquals( - tempDir.resolve("schema.sql").toString(), + List.of(tempDir.resolve("schema.sql").toString()), config.sql().getFirst().schema() ); assertEquals( @@ -56,6 +56,104 @@ void shouldMapVersionOneFields() throws IOException { ); } + @Test + void shouldResolveEveryListedSchemaPath() throws IOException { + Path configFile = write(""" + version: "1" + sql: + - name: Users + schema: + - sql/baseline.sql + - sql/migrations + queries: queries.sql + java: + package: dev.example.generated + out: generated + """); + + Config config = configLoader.load(configFile); + + assertEquals( + List.of( + tempDir.resolve("sql/baseline.sql").toString(), + tempDir.resolve("sql/migrations").toString() + ), + config.sql().getFirst().schema() + ); + } + + @Test + void shouldRejectBlankSchemaValue() throws IOException { + Path configFile = write(""" + version: "1" + sql: + - name: Users + schema: " " + queries: queries.sql + java: + package: dev.example.generated + out: generated + """); + + assertInvalid(configFile, "'sql[0].schema' must not be blank"); + } + + @Test + void shouldRejectEmptySchemaList() throws IOException { + Path configFile = write(""" + version: "1" + sql: + - name: Users + schema: [] + queries: queries.sql + java: + package: dev.example.generated + out: generated + """); + + assertInvalid(configFile, "'sql[0].schema' must contain at least one path"); + } + + @Test + void shouldRejectBlankListedSchemaPath() throws IOException { + Path configFile = write(""" + version: "1" + sql: + - name: Users + schema: + - schema.sql + - " " + queries: queries.sql + java: + package: dev.example.generated + out: generated + """); + + assertInvalid(configFile, "'sql[0].schema[1]' must not be blank"); + } + + @Test + void shouldRejectWrongTypedListedSchemaPath() throws IOException { + Path configFile = write(""" + version: "1" + sql: + - name: Users + schema: + - schema.sql + - 42 + queries: queries.sql + java: + package: dev.example.generated + out: generated + """); + + assertInvalid( + configFile, + "invalid 'sql[0].schema[1]' value '42' of type number, " + + "expected a string" + ); + } + @Test void shouldPreserveSqlEntryOrder() throws IOException { Path configFile = write(""" @@ -79,7 +177,7 @@ void shouldPreserveSqlEntryOrder() throws IOException { tempDir.resolve("users.sql").toString(), tempDir.resolve("orders.sql").toString() ), - config.sql().stream().map(SqlConfig::schema).toList() + config.sql().stream().flatMap(entry -> entry.schema().stream()).toList() ); assertEquals( @@ -115,7 +213,7 @@ void shouldResolveRelativePathsAgainstConfigurationDirectory() throws IOExceptio ); assertEquals( - tempDir.resolve("project/sql/schema.sql").toString(), + List.of(tempDir.resolve("project/sql/schema.sql").toString()), config.sql().getFirst().schema() ); @@ -153,7 +251,7 @@ void shouldKeepAbsolutePathsAbsolute() throws IOException { Config config = configLoader.load(configFile); assertEquals( - schemaFile.toString(), + List.of(schemaFile.toString()), config.sql().getFirst().schema() ); @@ -490,7 +588,7 @@ void shouldPreserveSqlEntryNameWhileResolvingPaths() throws IOException { assertEquals("Author", config.sql().getFirst().name()); assertEquals( - tempDir.resolve("sql/schema.sql").toString(), + List.of(tempDir.resolve("sql/schema.sql").toString()), config.sql().getFirst().schema() ); }