Skip to content

Commit 6ae99e0

Browse files
authored
Merge pull request #41 from sqlcj-dev/feature/schema-files-and-migration-directories
feat: read schema from file lists and migration directories
2 parents 607a86d + 012a99a commit 6ae99e0

12 files changed

Lines changed: 781 additions & 30 deletions

File tree

‎docs/configuration.md‎

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ java:
5656
| `version` | string | Configuration contract version. Must be `"1"`. |
5757
| `sql` | list | Non-empty, ordered list of source entries. |
5858
| `sql[].name` | string | Identity of the query group. Names the generated repository. |
59-
| `sql[].schema` | string | Path to a file containing `CREATE TABLE` statements. |
59+
| `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. |
6060
| `sql[].queries` | string | Path to a file containing named queries. |
6161
| `java` | mapping | Java generation settings. |
6262
| `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
7070
preserved.
7171

7272
Schemas are not shared or merged between entries. A query can only use tables
73-
declared in the schema file of its own entry.
73+
declared in the schema sources of its own entry.
7474

7575
One entry generates exactly one repository containing every query of its query
7676
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
9797
sqlcj does not derive the name from a table or a file name. A query file may
9898
join or write several tables, so the group boundary is declared, not guessed.
9999

100+
### `sql[].schema`
101+
102+
`sql[].schema` is either one path or a non-empty ordered list of paths:
103+
104+
```yaml
105+
sql:
106+
- name: Author
107+
schema: sql/authors/schema.sql
108+
queries: sql/authors/queries.sql
109+
- name: Order
110+
schema:
111+
- sql/orders/baseline.sql
112+
- sql/orders/migrations
113+
queries: sql/orders/queries.sql
114+
```
115+
116+
A listed path is either a file, which contributes itself, or a directory, which
117+
contributes the regular files in it whose name ends in `.sql`. A directory is
118+
read non-recursively, and the name match is case-sensitive: a subdirectory and a
119+
file named anything else, including `schema.SQL`, are ignored. The listed paths
120+
keep their declared order and each directory is expanded in place.
121+
122+
A directory is ordered like a Flyway migration directory:
123+
124+
1. A versioned migration file, named `V<version>__<description>.sql` with
125+
version parts separated by `.` or `_`, comes first. Two versions are compared
126+
part by part as numbers, counting a missing trailing part as zero, so
127+
`V1__init.sql`, `V1_1__add_index.sql`, `V2__add_orders.sql`, and
128+
`V10__add_totals.sql` are ordered exactly that way.
129+
2. Every other `.sql` file follows, ordered by file name.
130+
3. An undo file, named `U<version>__<description>.sql`, is ignored.
131+
132+
The order never depends on how the filesystem lists the directory. sqlcj only
133+
reads the files, so a Flyway placeholder, a configured file-name prefix, and a
134+
repeatable migration have no meaning of their own.
135+
136+
Each schema file is parsed on its own, and the tables of the entry follow file
137+
order, so a schema failure names the file that contains it:
138+
139+
```text
140+
sqlcj: Invalid schema source /home/dev/project/sql/orders/migrations/V2__add_orders.sql: Unsupported schema statement: Alter
141+
```
142+
143+
A directory that contributes no file, and two versioned files of one directory
144+
that declare the same version, are invalid schema input:
145+
146+
```text
147+
sqlcj: Invalid schema source /home/dev/project/sql/orders/migrations: directory contains no .sql files
148+
sqlcj: Invalid schema source /home/dev/project/sql/orders/migrations: duplicate migration version in V1_0__add_index.sql and V1__init.sql
149+
```
150+
151+
A configured path that cannot be read, whether it is a file or a directory,
152+
ends the run the same way:
153+
154+
```text
155+
sqlcj: Cannot read schema source: /home/dev/project/sql/orders/migrations: Permission denied
156+
```
157+
100158
### `java.package`
101159

102160
`java.package` must be a dot-separated sequence of valid, non-keyword Java
@@ -115,14 +173,14 @@ generated/dev/example/generated/AuthorRepository.java
115173
- String values must not be blank.
116174
- Unknown fields are rejected.
117175
- Wrong-typed fields are rejected, including an unquoted numeric `version`.
118-
- A missing or unsupported `version`, a missing section, an empty `sql` list, a
119-
null `sql` entry, a blank value, an invalid `sql[].name`, and an invalid
120-
`java.package` are all invalid configuration.
176+
- A missing or unsupported `version`, a missing section, an empty `sql` list, an
177+
empty `sql[].schema` list, a null `sql` entry, a blank value, an invalid
178+
`sql[].name`, and an invalid `java.package` are all invalid configuration.
121179

122180
## Path Resolution
123181

124-
A relative `schema`, `queries`, or `java.out` path is resolved against the
125-
directory that contains the configuration file, whether that file is the
182+
A relative `queries`, `java.out`, or listed `schema` path is resolved against
183+
the directory that contains the configuration file, whether that file is the
126184
default `sqlcj.yaml` or one named by `--config`, and not against the process
127185
working directory at a later point in time. An absolute path is used as-is.
128186

‎docs/postgresql.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,15 @@ test-scoped dependencies of the build.
8787

8888
## Schema Snapshot Input
8989

90-
A `sql[].schema` file is an explicit snapshot of the tables a query may use.
90+
`sql[].schema` is an explicit snapshot of the tables a query may use. It is one
91+
file, a list of files, or a directory of `.sql` migration files; see
92+
[`sql[].schema`](configuration.md#sqlschema) for the accepted forms and the
93+
order the files are read in.
9194

92-
- Only `CREATE TABLE` statements are accepted. Any other statement in the file
93-
is rejected.
95+
- Only `CREATE TABLE` statements are accepted. Any other statement in a schema
96+
file is rejected.
9497
- A file may contain several `CREATE TABLE` statements, and table order is
95-
preserved.
98+
preserved across the schema files of one entry.
9699
- SQL identifier delimiters are removed for the parsed model, so the table
97100
`"user data"` is modeled as `user data` and the column `"user id"` is modeled
98101
as `user id`.

‎sqlcj-cli/src/main/java/dev/sqlcj/compiler/DefaultSourceLoader.java‎

Lines changed: 143 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,29 @@
1010
import dev.sqlcj.parser.QueryParser;
1111

1212
import java.io.IOException;
13+
import java.math.BigInteger;
14+
import java.nio.file.Files;
1315
import java.nio.file.Path;
1416
import java.util.ArrayList;
17+
import java.util.Comparator;
1518
import java.util.List;
19+
import java.util.regex.Matcher;
20+
import java.util.regex.Pattern;
21+
import java.util.stream.Stream;
1622

1723
public final class DefaultSourceLoader implements SourceLoader {
1824

25+
private static final String SQL_SUFFIX = ".sql";
26+
27+
/** A Flyway versioned migration file, such as {@code V1_1__add_index.sql}. */
28+
private static final Pattern VERSIONED = Pattern.compile("V(\\d+([._]\\d+)*)__.*\\.sql");
29+
30+
/** A Flyway undo migration file, such as {@code U1_1__add_index.sql}. */
31+
private static final Pattern UNDO = Pattern.compile("U(\\d+([._]\\d+)*)__.*\\.sql");
32+
33+
/** One part of a migration version, separated by {@code .} or {@code _}. */
34+
private static final Pattern VERSION_SEPARATOR = Pattern.compile("[._]");
35+
1936
private final FileLoader fileLoader = new DefaultFileLoader();
2037
private final QueryParser queryParser = new DefaultQueryParser();
2138

@@ -29,17 +46,15 @@ public List<Source> load(Config config) {
2946
List<Source> sources = new ArrayList<>();
3047

3148
for (SqlConfig sqlConfig : config.sql()) {
32-
Path schemaPath = Path.of(sqlConfig.schema());
3349
Path queriesPath = Path.of(sqlConfig.queries());
3450

35-
String schema = read(schemaPath, "schema");
51+
List<SchemaFile> schemaFiles = loadSchema(sqlConfig.schema());
3652
List<Query> queries = parse(read(queriesPath, "queries"), queriesPath);
3753

3854
sources.add(
3955
new Source(
4056
sqlConfig.name(),
41-
schemaPath,
42-
schema,
57+
schemaFiles,
4358
queriesPath,
4459
queries
4560
)
@@ -49,6 +64,130 @@ public List<Source> load(Config config) {
4964
return sources;
5065
}
5166

67+
/**
68+
* Loads the schema files of one entry in the order they apply: the
69+
* configured paths in declared order, each directory expanded in place into
70+
* its own migration files.
71+
*/
72+
private List<SchemaFile> loadSchema(List<String> configuredPaths) {
73+
List<SchemaFile> schemaFiles = new ArrayList<>();
74+
75+
for (String configuredPath : configuredPaths) {
76+
Path path = Path.of(configuredPath);
77+
78+
if (Files.isDirectory(path)) {
79+
for (Path file : migrationFiles(path)) {
80+
schemaFiles.add(new SchemaFile(file, read(file, "schema")));
81+
}
82+
} else {
83+
schemaFiles.add(new SchemaFile(path, read(path, "schema")));
84+
}
85+
}
86+
87+
return List.copyOf(schemaFiles);
88+
}
89+
90+
/**
91+
* The {@code .sql} files one directory contributes, in Flyway migration
92+
* order: versioned files by version, then every other file by name. The
93+
* order never depends on how the filesystem lists the directory.
94+
*/
95+
private List<Path> migrationFiles(Path directory) {
96+
List<Path> candidates = list(directory).stream()
97+
.filter(Files::isRegularFile)
98+
.filter(path -> fileName(path).endsWith(SQL_SUFFIX))
99+
.filter(path -> !UNDO.matcher(fileName(path)).matches())
100+
.sorted(Comparator.comparing(this::fileName))
101+
.toList();
102+
103+
if (candidates.isEmpty()) {
104+
throw new CompilationException(
105+
"Invalid schema source %s: directory contains no .sql files"
106+
.formatted(directory)
107+
);
108+
}
109+
110+
List<Path> versioned = candidates.stream()
111+
.filter(path -> version(path) != null)
112+
.sorted(Comparator.comparing(this::version, this::compareVersions))
113+
.toList();
114+
115+
checkDuplicateVersions(directory, versioned);
116+
117+
List<Path> files = new ArrayList<>(versioned);
118+
119+
candidates.stream()
120+
.filter(path -> version(path) == null)
121+
.forEach(files::add);
122+
123+
return files;
124+
}
125+
126+
private List<Path> list(Path directory) {
127+
try (Stream<Path> entries = Files.list(directory)) {
128+
return entries.toList();
129+
} catch (IOException e) {
130+
throw new CompilationException(
131+
"Cannot read schema source: %s: %s"
132+
.formatted(directory, FileSystemReason.of(e)),
133+
e
134+
);
135+
}
136+
}
137+
138+
/** The declared version of a versioned migration file, or {@code null}. */
139+
private String version(Path path) {
140+
Matcher matcher = VERSIONED.matcher(fileName(path));
141+
142+
return matcher.matches() ? matcher.group(1) : null;
143+
}
144+
145+
/**
146+
* Compares two migration versions part by part as numbers, counting a
147+
* missing trailing part as zero, so that {@code 1} precedes {@code 1.1} and
148+
* {@code 2} precedes {@code 10}.
149+
*/
150+
private int compareVersions(String left, String right) {
151+
String[] leftParts = VERSION_SEPARATOR.split(left);
152+
String[] rightParts = VERSION_SEPARATOR.split(right);
153+
154+
for (int i = 0; i < Math.max(leftParts.length, rightParts.length); i++) {
155+
int comparison = part(leftParts, i).compareTo(part(rightParts, i));
156+
157+
if (comparison != 0) {
158+
return comparison;
159+
}
160+
}
161+
162+
return 0;
163+
}
164+
165+
private BigInteger part(String[] parts, int index) {
166+
return index < parts.length ? new BigInteger(parts[index]) : BigInteger.ZERO;
167+
}
168+
169+
/**
170+
* Rejects two versioned files of one directory that declare the same
171+
* version, because neither order of the two is the applied order.
172+
*/
173+
private void checkDuplicateVersions(Path directory, List<Path> versioned) {
174+
for (int i = 1; i < versioned.size(); i++) {
175+
Path previous = versioned.get(i - 1);
176+
Path current = versioned.get(i);
177+
178+
if (compareVersions(version(previous), version(current)) == 0) {
179+
throw new CompilationException(
180+
"Invalid schema source %s: duplicate migration version in %s and %s"
181+
.formatted(directory, fileName(previous), fileName(current))
182+
);
183+
}
184+
}
185+
}
186+
187+
private String fileName(Path path) {
188+
return path.getFileName().toString();
189+
}
190+
52191
private String read(Path path, String kind) {
53192
try {
54193
return fileLoader.read(path);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package dev.sqlcj.compiler;
2+
3+
import java.nio.file.Path;
4+
5+
/**
6+
* One loaded schema file and the path it was loaded from, which identifies it
7+
* in a compilation diagnostic.
8+
*/
9+
public record SchemaFile(
10+
Path path,
11+
String sql
12+
) {
13+
}

‎sqlcj-cli/src/main/java/dev/sqlcj/compiler/Source.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
* identify a source in a compilation diagnostic.
1111
*
1212
* @param name the configured group identity that names the generated repository
13+
* @param schemaFiles the schema files of the entry, in the order they apply
1314
*/
1415
public record Source(
1516
String name,
16-
Path schemaPath,
17-
String schema,
17+
List<SchemaFile> schemaFiles,
1818
Path queriesPath,
1919
List<Query> queries
2020
) {

‎sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import dev.sqlcj.io.GeneratedOutputManifest;
1212
import dev.sqlcj.parser.Query;
1313
import dev.sqlcj.schema.Schema;
14+
import dev.sqlcj.schema.Table;
1415
import dev.sqlcj.schema.parser.DefaultSchemaParser;
1516
import dev.sqlcj.schema.parser.SchemaParser;
1617
import dev.sqlcj.sql.ParsedSql;
@@ -89,12 +90,26 @@ private List<GeneratedFile> generate(List<Source> sources, CodeGenerator codeGen
8990
return files;
9091
}
9192

93+
/**
94+
* Parses the schema files of one entry in order into that entry's schema,
95+
* so a table keeps the position of the file that declares it.
96+
*/
9297
private Schema parseSchema(Source source) {
98+
List<Table> tables = new ArrayList<>();
99+
100+
for (SchemaFile schemaFile : source.schemaFiles()) {
101+
tables.addAll(parseSchemaFile(schemaFile).tables());
102+
}
103+
104+
return new Schema(tables);
105+
}
106+
107+
private Schema parseSchemaFile(SchemaFile schemaFile) {
93108
try {
94-
return schemaParser.parse(source.schema());
109+
return schemaParser.parse(schemaFile.sql());
95110
} catch (RuntimeException e) {
96111
throw new CompilationException(
97-
"Invalid schema source %s: %s".formatted(source.schemaPath(), reason(e)),
112+
"Invalid schema source %s: %s".formatted(schemaFile.path(), reason(e)),
98113
e
99114
);
100115
}

0 commit comments

Comments
 (0)