build/sqlc: implement simple sqlc/bindata rules

This is a first pass at implementing rules to generate sqlc stubs from
.sql files and embed relevant migration files into bindata.

This is not yet used. The Go API is subject to change - especially the
way migrations are laid out in the generated package will probably
change.

Change-Id: I0873031603957a176ad4664c3b10768b791e0dd5
Reviewed-on: https://review.monogon.dev/c/monogon/+/884
Tested-by: Jenkins CI
Reviewed-by: Leopold Schabel <leo@monogon.tech>
diff --git a/build/sqlc/sqlc.bzl b/build/sqlc/sqlc.bzl
new file mode 100644
index 0000000..3083fd3
--- /dev/null
+++ b/build/sqlc/sqlc.bzl
@@ -0,0 +1,182 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_context")
+
+# _parse_migrations takes a list of golang-migrate compatible schema files (in
+# the format <timestamp>_some_description_{up,down}.sql) and splits them into
+# 'up' and 'down' dictionaries, each a map from timestamp to underlying file.
+#
+# It also does some checks on the provided file names, making sure that
+# golang-migrate will parse them correctly.
+def _parse_migrations(files):
+    uppers = {}
+    downers = {}
+
+    # Ensure filename fits golang-migrate format, sort into 'up' and 'down' files.
+    for file in files:
+        if not file.basename.endswith(".up.sql") and not file.basename.endswith(".down.sql"):
+            fail("migration %s must end woth .{up,down}.sql" % file.basename)
+        if len(file.basename.split('.')) != 3:
+            fail("migration %s must not contain any . other than in .{up,down}.sql extension" % file.basename)
+        first = file.basename.split('.')[0]
+        if len(first.split('_')) < 2:
+            fail("migration %s must be in <timestamp>_<name>.{up,down}.sql format" % file.basename)
+        timestamp = first.split('_')[0]
+        if not timestamp.isdigit():
+            fail("migration %s must be in <timestamp>_<name>.{up,down}.sql format" % file.basename)
+        timestamp = int(timestamp)
+        if timestamp < 1662136250:
+            fail("migration %s must be in <timestamp>_<name>.{up,down}.sql format" % file.basename)
+
+        if file.basename.endswith('.up.sql'):
+            if timestamp in uppers:
+               fail("migration %s conflicts with %s" % [file.basename, uppers[timestamp].basename])
+            uppers[timestamp] = file
+        if file.basename.endswith('.down.sql'):
+            if timestamp in downers:
+               fail("migration %s conflicts with %s" % [file.basename, downers[timestamp].basename])
+            downers[timestamp] = file
+
+    # Check each 'up' has a corresponding 'down', and vice-versa.
+    for timestamp, up in uppers.items():
+        if timestamp not in downers:
+            fail("%s has no corresponding 'down' migration" % up.basename)
+        if downers[timestamp].basename.replace('down.sql', 'up.sql') != up.basename:
+            fail("%s has no corresponding 'down' migration" % up.basename)
+    for timestamp, down in downers.items():
+        if timestamp not in uppers:
+            fail("%s has no corresponding 'up' migration" % down.basename)
+        if uppers[timestamp].basename.replace('up.sql', 'down.sql') != down.basename:
+            fail("%s has no corresponding 'up' migration" % down.basename)
+
+    return uppers, downers
+
+def _sqlc_go_library(ctx):
+    go = go_context(ctx)
+
+    importpath_parts = ctx.attr.importpath.split("/")
+    package_name = importpath_parts[-1]
+
+    # Split migrations into 'up' and 'down'. Only pass 'up' to sqlc. Use both
+    # to generate golang-migrate compatible bindata.
+    uppers, downers = _parse_migrations(ctx.files.migrations)
+
+    # Make sure given queries have no repeating basenames. This ensures clean
+    # mapping source SQL file name and generated Go file.
+    query_basenames = []
+    for query in ctx.files.queries:
+        if query.basename in query_basenames:
+            fail("duplicate %s base name in query files" % query.basename)
+        query_basenames.append(query.basename)
+
+    # Go files generated by sqlc.
+    sqlc_go_sources = [
+        # db.go and models.go always exist.
+        ctx.actions.declare_file("db.go"),
+        ctx.actions.declare_file("models.go"),
+    ]
+    # For every query file, basename.go is also generated.
+    for basename in query_basenames:
+        sqlc_go_sources.append(ctx.actions.declare_file(basename + ".go"))
+
+    migrations_source = ctx.actions.declare_file("migrations.go")
+
+    # Cockroachdb is PostgreSQL with some extra overrides to fix Go/SQL type
+    # mappings.
+    overrides = []
+    if ctx.attr.dialect == "cockroachdb":
+        overrides = [
+            # INT is 64-bit in cockroachdb (32-bit in postgres).
+            { "go_type": "int64", "db_type": "pg_catalog.int4" },
+        ]
+
+    config = ctx.actions.declare_file("_config.yaml")
+    # All paths in config are relative to the config file. However, Bazel paths
+    # are relative to the execution root/CWD. To make things work regardless of
+    # config file placement, we prepend all config paths with a `../../ ...`
+    # path walk that makes the path be execroot relative again.
+    config_walk = '../' * config.path.count('/')
+    config_data = json.encode({
+        "version": 2,
+        "sql": [
+            {
+                "schema": [config_walk + up.path for up in uppers.values()],
+                "queries": [config_walk + query.path for query in ctx.files.queries],
+                "engine": "postgresql",
+                "gen": {
+                    "go": {
+                        "package": package_name,
+                        "out": config_walk + sqlc_go_sources[0].dirname,
+                        "overrides": overrides,
+                    },
+                },
+            },
+        ],
+    })
+    ctx.actions.write(config, config_data)
+
+    # Generate types/functions using sqlc.
+    ctx.actions.run(
+        mnemonic = "SqlcGen",
+        executable = ctx.executable._sqlc,
+        arguments = [
+            "generate",
+            "-f", config.path,
+        ],
+        inputs = [
+            config
+        ] + uppers.values() + ctx.files.queries,
+        outputs = sqlc_go_sources,
+    )
+
+    # Generate migrations bindata.
+    ctx.actions.run(
+        mnemonic = "MigrationsEmbed",
+        executable = ctx.executable._bindata,
+        arguments = [
+            "-pkg", package_name,
+            "-prefix", ctx.label.workspace_root,
+            "-o", migrations_source.path,
+        ] + [up.path for up in uppers.values()] + [down.path for down in downers.values()],
+        inputs = uppers.values() + downers.values(),
+        outputs = [migrations_source],
+    )
+
+    library = go.new_library(go, srcs = sqlc_go_sources + [migrations_source], importparth = ctx.attr.importpath)
+    source = go.library_to_source(go, ctx.attr, library, ctx.coverage_instrumented())
+    return [
+        library,
+        source,
+        OutputGroupInfo(go_generated_srcs = depset(library.srcs)),
+    ]
+
+
+sqlc_go_library = rule(
+    implementation = _sqlc_go_library,
+    attrs = {
+        "migrations": attr.label_list(
+            allow_files = True,
+        ),
+        "queries": attr.label_list(
+            allow_files = True,
+        ),
+        "importpath": attr.string(
+            mandatory = True,
+        ),
+        "dialect": attr.string(
+            mandatory = True,
+            values = ["postgresql", "cockroachdb"],
+        ),
+        "_sqlc": attr.label(
+            default = Label("@com_github_kyleconroy_sqlc//cmd/sqlc"),
+            allow_single_file = True,
+            executable = True,
+            cfg = "exec",
+        ),
+        "_bindata": attr.label(
+            default = Label("@com_github_kevinburke_go_bindata//go-bindata"),
+            allow_single_file = True,
+            executable = True,
+            cfg = "exec",
+        ),
+    },
+    toolchains = ["@io_bazel_rules_go//go:toolchain"],
+)