PostgreSQL adapter

This commit is contained in:
Bob Farrell 2024-06-16 12:53:25 +01:00
parent 81f7f8799c
commit 26f9dd71c0
40 changed files with 10282 additions and 249 deletions

View File

@ -1,3 +1,17 @@
# JetQuery
Database query library for [Jetzig](https://github.com/jetzig-framework/jetzig), a web framework written in [Zig](https://ziglang.org/).
## Testing
Use the provided _Docker Compose_ configuration to launch a local test database:
```console
docker compose up
```
Run tests:
```console
zig build test
```

134
build.zig
View File

@ -1,6 +1,6 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
@ -13,14 +13,146 @@ pub fn build(b: *std.Build) void {
b.installArtifact(lib);
const pg_dep = b.dependency("pg", .{ .target = target, .optimize = optimize });
const jetcommon_dep = b.dependency("jetcommon", .{ .target = target, .optimize = optimize });
const jetcommon_module = jetcommon_dep.module("jetcommon");
lib.root_module.addImport("pg", pg_dep.module("pg"));
lib.root_module.addImport("jetcommon", jetcommon_module);
const config_path = b.option([]const u8, "jetquery_config_path", "JetQuery configuration file path") orelse "jetquery.config.zig";
const config_module = if (try fileExist(config_path))
b.createModule(.{ .root_source_file = .{ .cwd_relative = config_path } })
else
b.createModule(.{ .root_source_file = b.path("src/default_config.zig") });
const jetquery_module = b.addModule("jetquery", .{ .root_source_file = b.path("src/jetquery.zig") });
jetquery_module.addImport("pg", pg_dep.module("pg"));
jetquery_module.addImport("jetcommon", jetcommon_module);
jetquery_module.addImport("jetquery.config", config_module);
const migrations_path = b.option([]const u8, "jetquery_migrations_path", "Migrations path") orelse
"migrations";
const lib_unit_tests = b.addTest(.{
.root_source_file = b.path("src/jetquery.zig"),
.target = target,
.optimize = optimize,
});
lib_unit_tests.root_module.addImport("pg", pg_dep.module("pg"));
lib_unit_tests.root_module.addImport("jetcommon", jetcommon_module);
lib_unit_tests.root_module.addImport("jetquery.config", config_module);
const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_lib_unit_tests.step);
const exe_generate_migrations = b.addExecutable(.{
.name = "migrations",
.root_source_file = b.path("src/generate_migrations.zig"),
.target = target,
.optimize = optimize,
});
const migration_unit_tests = b.addTest(.{
.root_source_file = b.path("src/jetquery/Migrate.zig"),
.target = target,
.optimize = optimize,
});
const run_migration_unit_tests = b.addRunArtifact(migration_unit_tests);
test_step.dependOn(&run_migration_unit_tests.step);
migration_unit_tests.step.dependOn(&exe_generate_migrations.step);
test_step.dependOn(&run_migration_unit_tests.step);
const run_generate_migrations_cmd = b.addRunArtifact(exe_generate_migrations);
const generated_migrations_path = run_generate_migrations_cmd.addOutputFileArg("migrations.zig");
for (try findMigrations(b.allocator, migrations_path)) |path| {
run_generate_migrations_cmd.addFileArg(.{ .cwd_relative = path });
}
const migrations_module = b.createModule(.{ .root_source_file = generated_migrations_path });
migrations_module.addImport("jetquery", jetquery_module);
migrations_module.addImport("jetquery.config", config_module);
migration_unit_tests.root_module.addImport("migrations", migrations_module);
migration_unit_tests.root_module.addImport("jetquery", jetquery_module);
migration_unit_tests.root_module.addImport("jetcommon", jetcommon_module);
const jetquery_migrate_module = b.addModule(
"jetquery_migrate",
.{ .root_source_file = b.path("src/jetquery/Migrate.zig") },
);
jetquery_migrate_module.addImport("jetquery", jetquery_module);
jetquery_migrate_module.addImport("migrations", migrations_module);
jetquery_migrate_module.addImport("jetquery.config", config_module);
jetquery_migrate_module.addImport("jetcommon", jetcommon_module);
const jetquery_reflect_module = b.addModule(
"jetquery_reflect",
.{ .root_source_file = b.path("src/jetquery/reflection/Reflect.zig") },
);
jetquery_reflect_module.addImport("jetquery", jetquery_module);
jetquery_reflect_module.addImport("migrations", migrations_module);
jetquery_reflect_module.addImport("jetquery.config", config_module);
jetquery_reflect_module.addImport("jetcommon", jetcommon_module);
const reflect_unit_tests = b.addTest(.{
.root_source_file = b.path("src/jetquery/reflection/Reflect.zig"),
.target = target,
.optimize = optimize,
});
const run_reflect_unit_tests = b.addRunArtifact(reflect_unit_tests);
reflect_unit_tests.root_module.addImport("jetquery", jetquery_module);
reflect_unit_tests.root_module.addImport("jetcommon", jetcommon_module);
test_step.dependOn(&run_reflect_unit_tests.step);
}
fn findMigrations(allocator: std.mem.Allocator, path: []const u8) ![][]const u8 {
const absolute_path = if (std.fs.path.isAbsolute(path))
path
else
std.fs.cwd().realpathAlloc(allocator, path) catch |err| {
switch (err) {
error.FileNotFound => return &.{},
else => return err,
}
};
var dir = std.fs.openDirAbsolute(absolute_path, .{ .iterate = true }) catch |err| {
switch (err) {
error.FileNotFound => return &.{},
else => return err,
}
};
defer dir.close();
var migrations = std.ArrayList([]const u8).init(allocator);
var it = dir.iterate();
while (try it.next()) |entry| {
if (entry.kind != .file) continue;
try migrations.append(try std.fs.path.join(allocator, &.{ absolute_path, entry.name }));
}
std.mem.sort([]const u8, migrations.items, {}, cmpString);
return try migrations.toOwnedSlice();
}
fn cmpString(_: void, lhs: []const u8, rhs: []const u8) bool {
return std.mem.order(u8, lhs, rhs).compare(.lt);
}
fn fileExist(path: []const u8) !bool {
const file = std.fs.cwd().openFile(path, .{}) catch |err| {
switch (err) {
error.FileNotFound => return false,
else => return err,
}
};
file.close();
return true;
}

View File

@ -1,72 +1,22 @@
.{
// This is the default name used by packages depending on this one. For
// example, when a user runs `zig fetch --save <url>`, this field is used
// as the key in the `dependencies` table. Although the user can choose a
// different name, most users will stick with this provided value.
//
// It is redundant to include "zig" in this name because it is already
// within the Zig package namespace.
.name = "jetquery",
// This is a [Semantic Version](https://semver.org/).
// In a future version of Zig it will be used for package deduplication.
.version = "0.0.0",
// This field is optional.
// This is currently advisory only; Zig does not yet do anything
// with this value.
//.minimum_zig_version = "0.11.0",
// This field is optional.
// Each dependency must either provide a `url` and `hash`, or a `path`.
// `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
// Once all dependencies are fetched, `zig build` no longer requires
// internet connectivity.
.minimum_zig_version = "0.14.0",
.dependencies = .{
// See `zig fetch --save <url>` for a command-line interface for adding dependencies.
//.example = .{
// // When updating this field to a new URL, be sure to delete the corresponding
// // `hash`, otherwise you are communicating that you expect to find the old hash at
// // the new URL.
// .url = "https://example.com/foo.tar.gz",
//
// // This is computed from the file contents of the directory of files that is
// // obtained after fetching `url` and applying the inclusion rules given by
// // `paths`.
// //
// // This field is the source of truth; packages do not come from a `url`; they
// // come from a `hash`. `url` is just one of many possible mirrors for how to
// // obtain a package matching this `hash`.
// //
// // Uses the [multihash](https://multiformats.io/multihash/) format.
// .hash = "...",
//
// // When this is provided, the package is found in a directory relative to the
// // build root. In this case the package's hash is irrelevant and therefore not
// // computed. This field and `url` are mutually exclusive.
// .path = "foo",
// // When this is set to `true`, a package is declared to be lazily
// // fetched. This makes the dependency only get fetched if it is
// // actually used.
// .lazy = false,
//},
.pg = .{
.url = "https://github.com/karlseguin/pg.zig/archive/9226a0a256cd000eee2f9652c8c03af8dcbed79b.tar.gz",
.hash = "12202b30ebf018ca398f665e51cae6d000fdfe2d08d5ec369e3110f762c548b154a4",
},
.jetcommon = .{
.url = "https://github.com/jetzig-framework/jetcommon/archive/a248776ba56d6cc2b160d593ac3305756adcd26e.tar.gz",
.hash = "1220a61e8650f84b28baf31fae5da31712aec4b711b3a41d11ed07c908bac96648d8",
},
},
// Specifies the set of files and directories that are included in this package.
// Only files and directories listed here are included in the `hash` that
// is computed for this package. Only files listed here will remain on disk
// when using the zig package manager. As a rule of thumb, one should list
// files required for compilation plus any license(s).
// Paths are relative to the build root. Use the empty string (`""`) to refer to
// the build root itself.
// A directory listed here means that all files within, recursively, are included.
.paths = .{
"build.zig",
"build.zig.zon",
"src",
// For example...
//"LICENSE",
//"README.md",
"LICENSE",
"README.md",
},
}

8
compose.yml Normal file
View File

@ -0,0 +1,8 @@
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: "postgres"
POSTGRES_PASSWORD: "password"
ports:
- 5432:5432

10
jetquery.config.zig Normal file
View File

@ -0,0 +1,10 @@
pub const database = .{
.testing = .{
.adapter = .postgresql,
.database = "postgres",
.username = "postgres",
.hostname = "127.0.0.1",
.password = "password",
.port = 5432,
},
};

View File

@ -0,0 +1,19 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"humans",
&.{
t.primaryKey("id", .{}),
t.column("name", .string, .{ .not_null = true, .unique = true }),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("humans", .{});
}

View File

@ -0,0 +1,23 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"cats",
&.{
t.primaryKey("id", .{}),
t.column("name", .string, .{ .not_null = true, .unique = true }),
t.column("paws", .integer, .{ .index = true }),
t.column("human_id", .integer, .{ .reference = .{ "humans", "id" } }),
t.timestamps(.{}),
},
.{},
);
try repo.createIndex("cats", &.{ "name", "paws" }, .{});
}
pub fn down(repo: anytype) !void {
try repo.dropTable("cats", .{});
}

11
src/default_config.zig Normal file
View File

@ -0,0 +1,11 @@
pub const database = .{
.development = .{
.adapter = .null,
},
.testing = .{
.adapter = .null,
},
.production = .{
.adapter = .null,
},
};

View File

@ -0,0 +1,61 @@
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer std.debug.assert(gpa.deinit() == .ok);
const gpa_allocator = gpa.allocator();
var arena = std.heap.ArenaAllocator.init(gpa_allocator);
const allocator = arena.allocator();
defer arena.deinit();
const args = try std.process.argsAlloc(allocator);
const migrations_module_path = args[1];
const migrations: [][]const u8 = if (args.len > 2) args[2..] else &.{};
const migrations_file = try std.fs.createFileAbsolute(migrations_module_path, .{});
const migrations_module_dir = std.fs.path.dirname(migrations_module_path).?;
const writer = migrations_file.writer();
try writer.writeAll(
\\const jetquery = @import("jetquery");
\\pub const Migration = struct {
\\ upFn: *const fn(repo: anytype) anyerror!void,
\\ downFn: *const fn(repo: anytype) anyerror!void,
\\ version: []const u8,
\\ name: []const u8,
\\};
\\pub const migrations = [_]Migration{
\\
);
for (migrations) |migration| {
const basename = std.fs.path.basename(migration);
const version = basename[0.."2000-01-01_12-00-00".len];
try std.fs.copyFileAbsolute(
migration,
try std.fs.path.join(allocator, &.{ migrations_module_dir, basename }),
.{},
);
try writer.print(
\\ .{{
\\ .upFn = @import("{0s}").up,
\\ .downFn = @import("{0s}").down,
\\ .version = "{1s}",
\\ .name = "{0s}",
\\ }},
\\
,
.{ try zigEscape(allocator, basename), try zigEscape(allocator, version) },
);
}
try writer.writeAll(
\\};
\\
);
migrations_file.close();
}
fn zigEscape(allocator: std.mem.Allocator, input: []const u8) ![]const u8 {
var buf = std.ArrayList(u8).init(allocator);
const writer = buf.writer();
try std.zig.stringEscape(input, "", .{}, writer);
return try buf.toOwnedSlice();
}

File diff suppressed because it is too large Load Diff

111
src/jetquery/Connection.zig Normal file
View File

@ -0,0 +1,111 @@
const jetquery = @import("../jetquery.zig");
pub const Connection = union(enum) {
postgresql: jetquery.adapters.PostgresqlAdapter.Connection,
pub fn execute(
self: Connection,
query: anytype,
caller_info: ?jetquery.debug.CallerInfo,
repo: anytype,
) !switch (@TypeOf(query).ResultContext) {
.one => ?@TypeOf(query).ResultType,
.many => jetquery.Result(@TypeOf(repo.*)),
.none => void,
} {
return switch (self) {
.postgresql => |*connection| result_blk: {
try query.validateValues();
try query.validateDelete();
var result = try connection.execute(
query.sql,
query.field_values,
caller_info,
repo,
);
break :result_blk switch (@TypeOf(query).ResultContext) {
.one => blk: {
// TODO: Create a new ResultContext `.unary` instead of hacking it in here.
if (query.query_context == .count) {
defer result.deinit();
const unary = try result.unary(@TypeOf(query).ResultType);
try result.drain();
return unary;
}
const row = try result.next(query);
defer result.deinit();
try result.drain();
break :blk row;
},
.many => result,
.none => blk: {
try result.drain();
defer result.deinit();
break :blk {};
},
};
},
};
}
/// Execute SQL with the active adapter without returning a result.
pub fn executeVoid(
self: Connection,
sql: []const u8,
values: anytype,
caller_info: ?jetquery.debug.CallerInfo,
repo: anytype,
) !void {
var result = switch (self) {
inline else => |connection| try connection.execute(sql, values, caller_info, repo),
};
try result.drain();
result.deinit();
}
/// Execute SQL with the active adapter and return a result (same as `execute` but accepts an
/// SQL string and values instead of a generated query).
pub fn executeSql(
self: Connection,
sql: []const u8,
values: anytype,
caller_info: ?jetquery.debug.CallerInfo,
repo: anytype,
) !jetquery.Result(@TypeOf(repo.*)) {
return switch (self) {
inline else => |connection| try connection.execute(sql, values, caller_info, repo),
};
}
/// Release connection to the pool.
pub fn release(self: Connection) void {
switch (self) {
inline else => |connection| connection.release(),
}
}
pub fn executeVoidRuntimeBind(
self: Connection,
sql: []const u8,
values: anytype,
comptime Args: type,
args: Args,
field_states: []const jetquery.sql.FieldState,
caller_info: ?jetquery.debug.CallerInfo,
repo: anytype,
) !void {
var result = switch (self) {
inline else => |connection| try connection.executeRuntimeBind(
sql,
values,
Args,
args,
field_states,
caller_info,
repo,
),
};
try result.drain();
result.deinit();
}
};

View File

@ -0,0 +1 @@
const std = @import("std");

212
src/jetquery/Migrate.zig Normal file
View File

@ -0,0 +1,212 @@
const std = @import("std");
const jetcommon = @import("jetcommon");
const migrations = @import("migrations").migrations;
const Migration = @import("migrations").Migration;
const jetquery = @import("jetquery");
pub const MigrateSchema = struct {
pub const Migrations = jetquery.Model(
@This(),
"jetquery_migrations",
struct {
version: []const u8,
name: []const u8,
created_at: jetquery.DateTime,
},
.{ .primary_key = .version },
);
};
pub fn Migrate(adapter_name: jetquery.adapters.Name) type {
return struct {
const Self = @This();
const AdaptedRepo = jetquery.Repo(adapter_name, MigrateSchema);
repo: *AdaptedRepo,
pub fn init(repo: *AdaptedRepo) Self {
return .{ .repo = repo };
}
/// Run migrations. Create `jetquery_migrations` table if it does not exist. Skip migrations
/// already present in `jetquery_migrations`.
pub fn migrate(self: Self) !void {
try self.createMigrationsTable();
log("\n* Running migrations.\n", .{});
var count: usize = 0;
inline for (migrations) |migration| {
if (!try self.isMigrated(migration)) {
log("\nExecuting migration: === {s} ===\n", .{migration.name});
try migration.upFn(self.repo);
try self.repo.Query(.Migrations)
.insert(.{ .version = migration.version, .name = migration.name })
.execute(self.repo);
count += 1;
log("\nCompleted migration: === {s} ===\n", .{migration.name});
}
}
log("\n* Applied {} migration(s).\n", .{count});
}
pub fn rollback(self: Self) !void {
if (migrations.len == 0) {
log("No applied migrations detected. Exiting.", .{});
return;
}
const last_migration = try self.repo.Query(.Migrations)
.orderBy(.{ .version = .desc })
.first(self.repo) orelse return;
defer self.repo.free(last_migration);
var applied = false;
inline for (migrations) |migration| {
if (!applied and std.mem.eql(u8, migration.version, last_migration.version)) {
log("\nRolling back migration: === {s} ===\n", .{migration.name});
try migration.downFn(self.repo);
try self.repo.delete(last_migration);
// Just in case we somehow end up with two migrations with the same version (e.g.
// user manually copied a file), we want to only apply one of them:
applied = true;
log("\nCompleted rollback: === {s} ===\n", .{migration.name});
}
}
}
fn isMigrated(self: Self, migration: Migration) !bool {
const result = try self.repo.Query(.Migrations)
.findBy(.{ .version = migration.version }).execute(self.repo);
return result != null;
}
fn createMigrationsTable(self: Self) !void {
try self.repo.createTable(
"jetquery_migrations",
&.{
jetquery.schema.table.primaryKey("version", .{ .type = .string }),
jetquery.schema.table.column("name", .string, .{ .not_null = true, .length = 1024 }),
jetquery.schema.table.timestamps(.{ .updated_at = false }),
},
.{ .if_not_exists = true },
);
}
fn log(comptime message: []const u8, args: anytype) void {
std.debug.print(message ++ "\n", args);
}
};
}
test "migrate" {
try resetDatabase();
const TestSchema = struct {
pub const Cat = jetquery.Model(@This(), "cats", struct {
id: i32,
name: []const u8,
paws: i8,
created_at: jetcommon.types.DateTime,
updated_at: jetcommon.types.DateTime,
human_id: i32,
}, .{});
pub const Human = jetquery.Model(
@This(),
"humans",
struct {
id: i32,
name: []const u8,
},
.{ .relations = .{ .cats = jetquery.relation.hasMany(.Cat, .{}) } },
);
};
var migrate_repo = try jetquery.Repo(.postgresql, MigrateSchema).init(
std.testing.allocator,
.{
.adapter = .{
.database = "migrate_test",
.username = "postgres",
.hostname = "127.0.0.1",
.password = "password",
.port = 5432,
},
},
);
defer migrate_repo.deinit();
const migrate = Migrate(.postgresql).init(&migrate_repo);
try migrate.migrate();
var test_repo = try jetquery.Repo(.postgresql, TestSchema).init(
std.testing.allocator,
.{
.adapter = .{
.database = "migrate_test",
.username = "postgres",
.hostname = "127.0.0.1",
.password = "password",
.port = 5432,
},
},
);
defer test_repo.deinit();
const migration = try migrate_repo.Query(.Migrations)
.findBy(.{ .version = "2024-08-26_13-18-52" })
.execute(&migrate_repo);
defer migrate_repo.free(migration);
try std.testing.expect(migration != null);
const query = test_repo.Query(.Human)
.join(.inner, .cats)
.select(.{ .id, .name, .{ .cats = .{ .name, .paws, .created_at, .updated_at } } });
var result = try test_repo.execute(query);
try result.drain();
defer result.deinit();
try migrate.rollback();
try std.testing.expectError(error.PG, test_repo.execute(query));
{
const humans = try test_repo.Query(.Human).all(&test_repo);
defer test_repo.free(humans);
}
try migrate.rollback();
{
const humans = test_repo.Query(.Human).all(&test_repo);
try std.testing.expectError(error.PG, humans);
}
}
fn resetDatabase() !void {
const S = struct {};
var repo = try jetquery.Repo(.postgresql, S).init(
std.testing.allocator,
.{
.adapter = .{
.database = "postgres",
.username = "postgres",
.hostname = "127.0.0.1",
.password = "password",
.port = 5432,
},
},
);
defer repo.deinit();
try repo.dropDatabase("migrate_test", .{ .if_exists = true });
try repo.createDatabase("migrate_test", .{});
}

626
src/jetquery/Migration.zig Normal file
View File

@ -0,0 +1,626 @@
const std = @import("std");
const jetcommon = @import("jetcommon");
allocator: std.mem.Allocator,
name: []const u8,
options: MigrationOptions,
const Migration = @This();
const MigrationOptions = struct {
migrations_path: ?[]const u8 = null,
command: ?[]const u8 = null,
};
pub fn init(allocator: std.mem.Allocator, name: []const u8, options: MigrationOptions) Migration {
return .{ .allocator = allocator, .name = name, .options = options };
}
const Command = struct {
command: []const u8,
allocator: std.mem.Allocator,
const Action = enum { create, drop, alter, rename };
const Modifier = enum {
action,
name,
rename,
type,
index,
unique,
reference,
};
pub const DataType = enum {
string,
integer,
float,
decimal,
boolean,
datetime,
text,
};
pub const Token = union(enum) {
table: Table,
column: Column,
pub const Table = struct {
name: ?[]const u8 = null,
rename: ?[]const u8 = null,
action: ?Action = null,
pub fn writeUp(self: Table, columns: []const Column, writer: anytype) !void {
switch (self.action orelse .create) {
.create => try self.writeCreateTable(columns, writer),
.drop => try self.writeDropTable(writer),
.alter => try self.writeAlterTable(columns, writer),
.rename => {}, // Covered by alterTable
}
}
pub fn writeDown(self: Table, writer: anytype) !void {
if ((self.action orelse .create) == .create) {
try writer.print(
\\try repo.dropTable("{s}", .{{}});
\\
,
.{self.name orelse return error.MissingTableName},
);
} else {
try writer.writeAll("_ = repo;");
}
}
fn writeCreateTable(self: Table, columns: []const Column, writer: anytype) !void {
try writer.print(
\\try repo.createTable("{s}",
, .{
self.name orelse return error.MissingTableName,
});
try writer.writeAll("&.{");
try writer.writeAll(
\\t.primaryKey("id", .{}),
);
for (columns) |column| {
try writeColumn(column, writer);
}
try writer.writeAll("t.timestamps(.{}),");
try writer.writeAll("},");
try writer.writeAll(".{},);");
}
fn writeDropTable(self: Table, writer: anytype) !void {
try writer.print(
\\try repo.dropTable("{s}", .{{}});
, .{self.name orelse return error.MissingTableName});
}
fn writeAlterTable(self: Table, columns: []const Column, writer: anytype) !void {
try writer.print(
\\try repo.alterTable("{s}", .{{
, .{self.name orelse return error.MissingTableName});
const has_columns = for (columns) |column| {
switch (column.action orelse .create) {
.create, .drop, .rename => break true,
else => unreachable,
}
} else false;
if (has_columns) try writer.writeAll(".columns = .{");
if (self.rename) |rename_table| {
try writer.print(
\\.rename = "{s}"{s}
, .{ rename_table, if (has_columns) "," else "" });
}
try writeAlterAddColumns(columns, writer);
try writeAlterDropColumns(columns, writer);
try writeAlterRenameColumns(columns, writer);
if (has_columns) try writer.writeAll("},");
try writer.writeAll("});");
}
fn writeAlterAddColumns(columns: []const Column, writer: anytype) !void {
const has_add_column = for (columns) |column| {
if (column.action orelse .create == .create) break true;
} else false;
if (has_add_column) try writer.writeAll(".add = &.{");
for (columns) |column| {
if (column.action orelse .create == .create) {
try writeColumn(column, writer);
}
}
if (has_add_column) try writer.writeAll("},");
}
fn writeAlterDropColumns(columns: []const Column, writer: anytype) !void {
const has_drop_column = for (columns) |column| {
if (column.action orelse .create == .drop) break true;
} else false;
if (has_drop_column) try writer.writeAll(".drop = &.{");
for (columns) |column| {
if (column.action orelse .create == .drop) {
try writer.print(
\\"{s},"
,
.{column.name orelse return error.MissingColumnName},
);
}
}
if (has_drop_column) try writer.writeAll("},");
}
fn writeAlterRenameColumns(columns: []const Column, writer: anytype) !void {
var count: usize = 0;
for (columns) |column| {
if (column.action orelse .create == .rename) {
try writer.print(
\\.rename = .{{ .from = "{s}", .to = "{s}" }},
,
.{
column.name orelse return error.MissingColumnName,
column.rename orelse return error.MissingColumnName,
},
);
count += 1;
}
}
if (count > 1) {
std.log.err(
"Multiple column renames are not permitted. " ++
"Create separate migrations to rename multiple columns.",
.{},
);
}
}
fn writeColumn(column: Column, writer: anytype) !void {
try writer.print(
\\t.column("{s}", .{s}, .{{
, .{
column.name orelse return error.MissingColumnName,
@tagName(column.type orelse .string),
});
var options_count: usize = 0;
inline for (comptime std.enums.values(Column.options)) |tag| {
if (@field(column, @tagName(tag))) |option| {
if (option) options_count += 1;
}
}
if (column.reference_column) |_| options_count += 1;
var index: usize = 0;
inline for (comptime std.enums.values(Column.options)) |field| {
if (@field(column, @tagName(field))) |option| {
if (option) {
const sep = if (index + 1 < options_count) "," else "";
try writer.print(".{s} = true{s}", .{ @tagName(field), sep });
index += 1;
}
}
}
if (try column.referenceInfo()) |reference_info| {
try writer.print(
\\.reference = .{{"{s}", "{s}"}}
, .{ reference_info[0], reference_info[1] });
}
try writer.writeAll("}),");
}
};
pub const Column = struct {
name: ?[]const u8 = null,
rename: ?[]const u8 = null,
action: ?Action = null,
type: ?DataType = null,
index: ?bool = null,
unique: ?bool = null,
not_null: ?bool = null,
reference: ?bool = null,
reference_column: ?[]const u8 = null,
pub const options = enum { unique, not_null, index };
pub fn referenceInfo(self: Column) !?[2][]const u8 {
if (self.reference_column == null) return null;
var info: [2][]const u8 = undefined;
var it = std.mem.tokenizeScalar(u8, self.reference_column.?, '.');
var index: usize = 0;
while (it.next()) |identifier| : (index += 1) {
std.debug.assert(index < 2);
info[index] = identifier;
}
std.debug.assert(index == 2);
return info;
}
};
pub fn hasName(self: Token) bool {
return switch (self) {
inline else => |token| blk: {
if (@hasField(@TypeOf(token), "name")) {
break :blk token.name != null;
} else {
// If we don't have a name field, return true to allow other attributes
// to be assigned (name always takes precedence as we expect it as the
// first token after the object designator, e.g.
// `column:foobar:index:unique` - `foobar` is the name.
break :blk true;
}
},
};
}
pub fn hasAction(self: Token) bool {
return switch (self) {
inline else => |token| token.action != null,
};
}
pub fn isRename(self: Token) bool {
return switch (self) {
inline else => |token| token.action orelse .create == .rename,
};
}
pub fn hasRename(self: Token) bool {
return switch (self) {
inline else => |token| token.rename != null,
};
}
pub fn set(self: *Token, modifier: Modifier, value: anytype) void {
switch (self.*) {
inline else => |*token| {
const T = @TypeOf(token.*);
inline for (std.meta.fields(T)) |field| {
if (comptime hasField(T, field.name, @TypeOf(value))) {
if (std.mem.eql(u8, field.name, @tagName(modifier))) {
@field(token, field.name) = @field(token, field.name) orelse value;
}
} else if (comptime hasEnum(T, field.name, @TypeOf(value))) {
inline for (comptime std.enums.values(FieldType(T, field.name))) |tag| {
if (std.mem.eql(u8, @tagName(tag), value)) {
@field(token, field.name) = tag;
}
}
}
}
},
}
}
};
fn isAction(modifier: []const u8) bool {
for (std.enums.values(Action)) |action| {
if (std.mem.eql(u8, @tagName(action), modifier)) return true;
}
return false;
}
const TokenIterator = struct {
arg_iterator: *std.mem.TokenIterator(u8, .any),
pub fn next(self: TokenIterator) !?Token {
while (self.arg_iterator.next()) |arg| {
var modifiers_it = std.mem.tokenizeScalar(u8, arg, ':');
var maybe_token: ?Token = null;
while (modifiers_it.next()) |modifier| {
if (maybe_token) |*token| {
if (!token.hasAction() and isAction(modifier)) {
token.set(.action, modifier);
} else if (!token.hasName()) {
token.set(.name, modifier);
} else if (modifierToken(modifier)) |modifier_token| {
token.set(modifier_token, true);
} else if (isType(modifier)) {
token.set(.type, modifier);
} else if (token.isRename() and !token.hasRename()) {
token.set(.rename, modifier);
} else if (token.* == .column and token.*.column.reference == true) {
token.column.reference_column = modifier;
} else {
return error.InvalidMigrationCommand;
}
} else {
maybe_token = if (std.mem.eql(u8, modifier, "table"))
Token{ .table = .{} }
else if (std.mem.eql(u8, modifier, "column"))
Token{ .column = .{} }
else if (std.mem.eql(u8, modifier, "rename"))
Token{ .table = .{ .action = .alter, .rename = modifier } }
else {
return error.InvalidMigrationCommand;
};
}
}
return maybe_token;
}
return null;
}
fn modifierToken(modifier: []const u8) ?Modifier {
inline for (comptime std.enums.values(Modifier)) |value| {
if (std.mem.eql(u8, @tagName(value), modifier)) return value;
}
return null;
}
};
pub fn write(self: Command, writer: anytype) !void {
var arg_iterator = std.mem.tokenizeAny(u8, self.command, &std.ascii.whitespace);
var token_iterator = TokenIterator{ .arg_iterator = &arg_iterator };
var up_buf = std.ArrayList(u8).init(self.allocator);
const up_writer = up_buf.writer();
var down_buf = std.ArrayList(u8).init(self.allocator);
const down_writer = down_buf.writer();
var columns = std.ArrayList(Command.Token.Column).init(self.allocator);
var maybe_table: ?Command.Token.Table = null;
while (try token_iterator.next()) |token| {
switch (token) {
.table => |table| {
maybe_table = table;
},
.column => |column| {
try columns.append(column);
},
}
}
if (maybe_table) |table| {
try table.writeUp(columns.items, up_writer);
try table.writeDown(down_writer);
}
try writer.print(migration_template, .{ up_buf.items, down_buf.items });
}
};
const migration_template =
\\const std = @import("std");
\\const jetquery = @import("jetquery");
\\const t = jetquery.schema.table;
\\
\\pub fn up(repo: anytype) !void {{
\\{s}
\\}}
\\
\\pub fn down(repo: anytype) !void {{
\\{s}
\\}}
\\
;
const default_migration = std.fmt.comptimePrint(migration_template, .{
\\ try repo.createTable(
\\ "my_table",
\\ &.{
\\ t.primaryKey("id", .{{}}),
\\ t.column("my_string", .string, .{{}}),
\\ t.column("my_integer", .integer, .{{}}),
\\ t.timestamps(.{{}}),
\\ },
\\ .{{}},
\\ );
,
\\ try repo.dropTable("my_table", .{{}});
});
pub fn save(self: Migration) ![]const u8 {
const content = try self.render();
var dir = if (self.options.migrations_path) |path|
try std.fs.openDirAbsolute(path, .{})
else
try std.fs.cwd().openDir("migrations", .{});
defer dir.close();
var timestamp_buf: [19]u8 = undefined;
const prefix = try timestamp(&timestamp_buf);
const filename = try std.mem.concat(self.allocator, u8, &.{ prefix, "_", self.name, ".zig" });
const migration_file = try dir.createFile(filename, .{ .exclusive = true });
defer migration_file.close();
const writer = migration_file.writer();
try writer.writeAll(content);
const realpath = try dir.realpathAlloc(self.allocator, filename);
return realpath;
}
pub fn render(self: Migration) ![]const u8 {
var arena = std.heap.ArenaAllocator.init(self.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var buf = std.ArrayList(u8).init(alloc);
const writer = buf.writer();
if (self.options.command) |cmd| {
const command = Command{ .allocator = alloc, .command = cmd };
try command.write(writer);
} else {
try writer.writeAll(default_migration);
}
return try jetcommon.fmt.zig(
self.allocator,
buf.items,
"Found errors in generated migration.",
);
}
fn timestamp(buf: []u8) ![]const u8 {
const datetime = jetcommon.types.DateTime.now();
const date = datetime.date();
const time = datetime.time();
var stream = std.io.fixedBufferStream(buf);
const writer = stream.writer();
try writer.print(
"{d:04}-{d:02}-{d:02}_{d:02}-{d:02}-{d:02}",
// TODO: Fix jetcommon types to expose these directly
.{ @as(u16, @intCast(date.zul_date.year)), date.zul_date.month, date.zul_date.day, time.zul_time.hour, time.zul_time.min, time.zul_time.sec },
);
return stream.getWritten();
}
inline fn FieldType(T: type, comptime name: []const u8) type {
const tag = std.enums.nameCast(std.meta.FieldEnum(T), name);
const F = std.meta.fieldInfo(T, tag);
return switch (@typeInfo(F.type)) {
.optional => |info| info.child,
else => F,
};
}
inline fn hasField(T: type, comptime name: []const u8, VT: type) bool {
return @hasField(T, name) and FieldType(T, name) == VT;
}
inline fn hasEnum(T: type, comptime name: []const u8, VT: type) bool {
if (VT != []const u8) return false;
if (!@hasField(T, name)) return false;
const FT = FieldType(T, name);
return @typeInfo(FT) == .@"enum";
}
inline fn isType(name: []const u8) bool {
inline for (comptime std.enums.values(Command.DataType)) |tag| {
if (std.mem.eql(u8, name, @tagName(tag))) return true;
}
return false;
}
test "default migration" {
const migration = Migration.init(std.testing.allocator, "test_migration", .{});
const rendered = try migration.render();
defer std.testing.allocator.free(rendered);
try std.testing.expectEqualStrings(default_migration, rendered);
}
test "migration from command line: create table" {
const command = "table:create:cats column:name:string:index:unique column:paws:integer column:human_id:index:reference:humans.id";
const migration = Migration.init(
std.testing.allocator,
"test_migration",
.{ .command = command },
);
const rendered = try migration.render();
defer std.testing.allocator.free(rendered);
try std.testing.expectEqualStrings(
\\const std = @import("std");
\\const jetquery = @import("jetquery");
\\const t = jetquery.schema.table;
\\
\\pub fn up(repo: anytype) !void {
\\ try repo.createTable(
\\ "cats",
\\ &.{
\\ t.primaryKey("id", .{}),
\\ t.column("name", .string, .{ .unique = true, .index = true }),
\\ t.column("paws", .integer, .{}),
\\ t.column("human_id", .string, .{ .index = true, .reference = .{ "humans", "id" } }),
\\ t.timestamps(.{}),
\\ },
\\ .{},
\\ );
\\}
\\
\\pub fn down(repo: anytype) !void {
\\ try repo.dropTable("cats", .{});
\\}
\\
, rendered);
}
test "migration from command line: drop table" {
const command = "table:drop:cats";
const migration = Migration.init(
std.testing.allocator,
"test_migration",
.{ .command = command },
);
const rendered = try migration.render();
defer std.testing.allocator.free(rendered);
try std.testing.expectEqualStrings(
\\const std = @import("std");
\\const jetquery = @import("jetquery");
\\const t = jetquery.schema.table;
\\
\\pub fn up(repo: anytype) !void {
\\ try repo.dropTable("cats", .{});
\\}
\\
\\pub fn down(repo: anytype) !void {
\\ _ = repo;
\\}
\\
, rendered);
}
test "migration from command line: alter table" {
// XXX: This is an incoherent migration (renaming table while adding columns not permitted)
// but it tests a lot of variations all in one command. We let the database fail if the
// migration is not coherent.
const command = "table:alter:cats column:color:string:index:unique column:rename:paws:feet column:drop:name rename:dogs";
const migration = Migration.init(
std.testing.allocator,
"test_migration",
.{ .command = command },
);
const rendered = try migration.render();
defer std.testing.allocator.free(rendered);
try std.testing.expectEqualStrings(
\\const std = @import("std");
\\const jetquery = @import("jetquery");
\\const t = jetquery.schema.table;
\\
\\pub fn up(repo: anytype) !void {
\\ try repo.alterTable("dogs", .{
\\ .columns = .{
\\ .rename = "rename",
\\ .add = &.{
\\ t.column("color", .string, .{ .unique = true, .index = true }),
\\ },
\\ .drop = &.{"name,"},
\\ .rename = .{ .from = "paws", .to = "feet" },
\\ },
\\ });
\\}
\\
\\pub fn down(repo: anytype) !void {
\\ _ = repo;
\\}
\\
, rendered);
}

133
src/jetquery/Model.zig Normal file
View File

@ -0,0 +1,133 @@
const std = @import("std");
const jetquery = @import("../jetquery.zig");
/// Abstraction of a database table. Define a schema with:
/// ```zig
/// const Schema = struct {
/// pub const Cat = Table("cats", struct { name: []const u8, paws: usize }, .{});
/// };
/// ```
pub fn Model(Schema: type, comptime table_name: []const u8, T: type, options: anytype) type {
return struct {
// TODO: Implement `format()`
pub const Definition = T;
const Self = @This();
pub const name = table_name;
pub const info = .{ .schema = Schema };
pub const relations = if (@hasField(
@TypeOf(options),
"relations",
)) options.relations else .{};
pub const primary_key = if (@hasField(
@TypeOf(options),
"primary_key",
)) jetquery.util.stringMaybeEnum(options.primary_key) else "id";
pub fn init(args: anytype) RecordType(@TypeOf(args)) {
var record: RecordType(@TypeOf(args)) = undefined;
record.__jetquery = .{ .args = args };
inline for (std.meta.fields(@TypeOf(args))) |field| {
@field(record, field.name) = @field(args, field.name);
}
return record;
}
pub fn Relation(comptime relation_name: []const u8) type {
comptime {
for (relations) |relation| {
if (std.mem.eql(u8, relation.relation_name, relation_name)) {
return relation;
}
}
@compileError(std.fmt.comptimePrint(
"Failed matching relation `{s}` on `{s}`",
.{ relation_name, name },
));
}
}
pub fn columns() [std.meta.fields(Definition).len]jetquery.columns.Column {
comptime {
const fields = std.meta.fields(Definition);
var buf: [fields.len]jetquery.columns.Column = undefined;
for (fields, 0..) |field, index| {
buf[index] = .{
.name = field.name,
.table = @This(),
.type = field.type,
};
}
return buf;
}
}
pub fn column(comptime column_name: []const u8) jetquery.columns.Column {
comptime {
return for (columns()) |col| {
if (std.mem.eql(u8, column_name, col.name)) break col;
} else @compileError(std.fmt.comptimePrint(
"No column named `{s}` defined in Schema for `{s}'",
.{ column_name, table_name },
));
}
}
fn RecordType(Args: type) type {
comptime {
const fields = std.meta.fields(T);
var struct_fields: [fields.len + 3]std.builtin.Type.StructField = undefined;
for (fields, 0..) |field, index| {
struct_fields[index] = jetquery.fields.structField(field.name, field.type);
}
struct_fields[fields.len] = jetquery.fields.structFieldComptime(
"__jetquery_model",
@This(),
);
struct_fields[fields.len + 1] = jetquery.fields.structFieldComptime(
"__jetquery_schema",
Schema,
);
const args_field = jetquery.fields.structField("args", Args);
const JetQuery = jetquery.fields.structType(&.{args_field});
struct_fields[fields.len + 2] = jetquery.fields.structField(
"__jetquery",
JetQuery,
);
return jetquery.fields.structType(&struct_fields);
}
}
pub fn defaultForeignKey() []const u8 {
comptime {
for (@typeInfo(Schema).@"struct".decls) |decl| {
const table = @field(Schema, decl.name);
if (std.mem.eql(u8, table.name, @This().name)) {
var buf: [decl.name.len]u8 = undefined;
return std.ascii.lowerString(&buf, decl.name) ++ "_id";
}
}
@compileError("Failed matching `" ++ @typeName(@This()) ++ "` in schema.");
}
}
pub fn defaultOrderBy() []const jetquery.sql.OrderClause {
if (!@hasField(Definition, primary_key)) return &.{};
return &.{.{ .column = column(primary_key), .direction = .ascending }};
}
};
}

1400
src/jetquery/Query.zig Normal file

File diff suppressed because it is too large Load Diff

1382
src/jetquery/Repo.zig Normal file

File diff suppressed because it is too large Load Diff

365
src/jetquery/Result.zig Normal file
View File

@ -0,0 +1,365 @@
const std = @import("std");
const jetquery = @import("../jetquery.zig");
const AuxiliaryQuery = @import("Query.zig").AuxiliaryQuery;
/// A result of an executed query.
pub fn Result(AdaptedRepo: type) type {
return union(enum) {
postgresql: jetquery.adapters.PostgresqlAdapter.Result(AdaptedRepo),
const Self = @This();
pub fn deinit(self: *Self) void {
switch (self.*) {
inline else => |*adapted_result| adapted_result.deinit(),
}
}
pub fn drain(self: *Self) !void {
switch (self.*) {
inline else => |*adapted_result| try adapted_result.drain(),
}
}
pub fn next(self: *Self, query: anytype) !?@TypeOf(query).ResultType {
const ResultType = @TypeOf(query).ResultType;
return switch (self.*) {
inline else => |*adapted_result| blk: {
var row = try adapted_result.next(query) orelse break :blk null;
errdefer adapted_result.deinit();
errdefer adapted_result.repo._freeRow(row);
extendInternalFields(@TypeOf(query), &row);
const primary_key = @TypeOf(query).info.Model.primary_key;
const primary_key_present = @hasField(
@TypeOf(query).info.Model.Definition,
primary_key,
);
// Create a secondary connection for fetching relations if needed. This allows us
// to continue iterating over the primary query and fetching relations on each
// iteration. Since these connections are backed by a pool (in pg.zig) we should
// be okay acquiring a new connection for each call to `next()`.
var connection = if (query.auxiliary_queries.len > 0)
try adapted_result.repo.connect()
else {};
defer if (query.auxiliary_queries.len > 0) connection.release();
inline for (query.auxiliary_queries) |aux_query| {
const foreign_key = comptime aux_query.relation.foreign_key orelse
@TypeOf(query).info.Model.defaultForeignKey();
const Args = WhereArgs(aux_query, foreign_key, .one);
var args: Args = undefined;
const q = if (comptime primary_key_present) q_blk: {
@field(args, foreign_key) = @field(row, primary_key);
break :q_blk aux_query.baseQuery().where(args);
} else @compileError(std.fmt.comptimePrint(
"Unable to fetch relation records for `{s}` without primary key.",
.{aux_query.relation.relation_name},
));
var aux_result = try connection.execute(
q,
adapted_result.caller_info,
adapted_result.repo,
);
defer aux_result.deinit();
const aux_type = AuxType(ResultType, aux_query.relation);
var aux_rows = std.ArrayList(aux_type).init(adapted_result.allocator);
while (try aux_result.next(q)) |aux_row| {
try aux_rows.append(mergeAux(
aux_type,
q,
@TypeOf(aux_row),
aux_row,
));
}
@field(row, aux_query.relation.relation_name) = try aux_rows.toOwnedSlice();
}
break :blk row;
},
};
}
pub fn all(self: *Self, query: anytype) ![]@TypeOf(query).ResultType {
const ResultType = @TypeOf(query).ResultType;
return switch (self.*) {
inline else => |*adapted_result| blk: {
var rows = try adapted_result.all(query);
const MergedRow = MergedRowType(query.auxiliary_queries, ResultType);
const primary_key = @TypeOf(query).info.Model.primary_key;
const primary_key_present = @hasField(
@TypeOf(query).info.Model.Definition,
primary_key,
);
var map = Map(@TypeOf(query), MergedRow, primary_key)
.init(adapted_result.allocator);
defer map.deinit();
for (rows, 0..) |row, index| {
if (comptime primary_key_present) {
const id = @field(row, primary_key);
try map.id_map.put(id, index);
try map.id_array.append(id);
}
var adapted_row = row;
extendInternalFields(@TypeOf(query), &adapted_row);
var merged_row: MergedRow = undefined;
inline for (query.auxiliary_queries) |init_aux_query| {
const aux_type = AuxType(ResultType, init_aux_query.relation);
@field(
merged_row,
init_aux_query.relation.relation_name,
) = std.ArrayList(aux_type).init(adapted_result.allocator);
}
const aux_values = try map.aux_map.getOrPut(index);
aux_values.value_ptr.* = merged_row;
rows[index] = adapted_row;
}
// Execute secondary queries (hasMany relations when `include` is used) where
// foreign keys match the primary keys returned by the primary query, then merge
// the results together.
inline for (query.auxiliary_queries) |aux_query| {
const foreign_key = comptime aux_query.relation.foreign_key orelse
@TypeOf(query).info.Model.defaultForeignKey();
const Args = WhereArgs(aux_query, foreign_key, .many);
var args: Args = undefined;
const q = if (comptime primary_key_present) q_blk: {
@field(args, foreign_key) = map.id_array.items;
break :q_blk aux_query.baseQuery().where(args);
} else @compileError(std.fmt.comptimePrint(
"Unable to fetch relation records for `{s}` without primary key.",
.{aux_query.relation.relation_name},
));
var aux_result = try adapted_result.repo.executeInternal(
q,
adapted_result.caller_info,
);
defer aux_result.deinit();
const aux_type = AuxType(ResultType, aux_query.relation);
while (try aux_result.next(q)) |aux_row| {
const adapted_aux_row = mergeAux(
aux_type,
q,
@TypeOf(aux_row),
aux_row,
);
if (comptime primary_key_present) try mapAux(
aux_query,
aux_type,
adapted_aux_row,
@TypeOf(aux_row),
aux_row,
foreign_key,
@TypeOf(map),
&map,
);
}
try aux_result.drain();
}
var it = map.aux_map.iterator();
while (it.next()) |entry| {
inline for (std.meta.fields(@TypeOf(entry.value_ptr.*))) |field| {
@field(rows[entry.key_ptr.*], field.name) = try @field(
entry.value_ptr.*,
field.name,
).toOwnedSlice();
}
}
break :blk rows;
},
};
}
pub fn unary(self: *Self, T: type) !T {
return switch (self.*) {
inline else => |*adapted_result| try adapted_result.unary(T),
};
}
fn extendInternalFields(Query: type, result: *Query.ResultType) void {
result.__jetquery_model = Query.info.Model;
result.__jetquery_schema = Query.info.Schema;
const originals = std.meta.fields(@TypeOf(result.__jetquery.original_values));
inline for (originals) |field| {
@field(result.__jetquery.original_values, field.name) = @field(result, field.name);
}
inline for (Query.relations) |relation| {
if (comptime relation.relation_type != .belongs_to) continue;
inline for (relation.select_columns) |select_column| {
const relation_field = @field(result, relation.relation_name);
const value = @field(relation_field, select_column.name);
@field(
@field(result, relation.relation_name).__jetquery.original_values,
select_column.name,
) = value;
}
}
}
pub inline fn duration(self: Result) i64 {
return switch (self) {
inline else => |adapted_result| adapted_result.duration,
};
}
fn mergeAux(aux_type: type, q: anytype, T: type, aux_row: T) aux_type {
var extended_aux_row = aux_row;
extendInternalFields(@TypeOf(q), &extended_aux_row);
var adapted_aux_row: aux_type = undefined;
inline for (std.meta.fields(aux_type)) |field| {
if (comptime std.mem.startsWith(u8, field.name, "__jetquery")) continue;
@field(adapted_aux_row, field.name) = @field(aux_row, field.name);
@field(
adapted_aux_row.__jetquery.original_values,
field.name,
) = @field(aux_row, field.name);
}
return adapted_aux_row;
}
fn mapAux(
aux_query: anytype,
aux_type: type,
adapted_aux_row: aux_type,
T: type,
aux_row: T,
comptime foreign_key: []const u8,
MapType: type,
map: *MapType,
) !void {
const foreign_key_value = @field(aux_row, foreign_key);
const maybe_row_index = switch (@typeInfo(@TypeOf(foreign_key_value))) {
.optional => if (foreign_key_value) |value|
map.id_map.get(value)
else
null,
else => map.id_map.get(foreign_key_value),
};
if (maybe_row_index) |row_index| {
// We pre-fill the map with an empty `MergedRow` so this is
// guaranteed to exist (or we have a bug).
const aux_values = map.aux_map.getEntry(row_index).?;
try @field(
aux_values.value_ptr.*,
aux_query.relation.relation_name,
).append(adapted_aux_row);
}
}
};
}
fn MergedRowType(auxiliary_queries: []const AuxiliaryQuery, ResultType: type) type {
var fields: [auxiliary_queries.len]std.builtin.Type.StructField = undefined;
for (auxiliary_queries, 0..) |aux_query, index| {
fields[index] = jetquery.fields.structField(
aux_query.relation.relation_name,
std.ArrayList(AuxType(ResultType, aux_query.relation)),
);
}
return jetquery.fields.structType(&fields);
}
fn AuxType(ResultType: type, Relation: type) type {
const field_name = std.enums.nameCast(
std.meta.FieldEnum(ResultType),
Relation.relation_name,
);
return switch (@typeInfo(std.meta.fieldInfo(ResultType, field_name).type)) {
.pointer => |info| info.child,
inline else => |tag| @compileError(std.fmt.comptimePrint(
"Expected slice for relation, found: `{s}`",
.{@tagName(tag) ++ "`"},
)),
};
}
fn WhereArgs(
aux_query: AuxiliaryQuery,
comptime foreign_key: []const u8,
arg_context: enum { one, many },
) type {
const field_type = jetquery.fields.fieldType(
aux_query.relation.Source.Definition,
foreign_key,
);
comptime {
var fields: [1]std.builtin.Type.StructField = .{jetquery.fields.structField(
foreign_key,
switch (arg_context) {
.one => field_type,
.many => []const field_type,
},
)};
return jetquery.fields.structType(&fields);
}
}
fn IdMap(Query: type, comptime primary_key: []const u8) type {
const PK = if (comptime @hasField(Query.info.Model.Definition, primary_key))
jetquery.fields.fieldType(Query.info.Model.Definition, primary_key)
else
void;
return switch (PK) {
[]const u8 => std.StringHashMap(usize),
else => std.AutoHashMap(PK, usize),
};
}
fn PrimaryKey(Query: type, comptime primary_key: []const u8) type {
return if (comptime @hasField(Query.info.Model.Definition, primary_key))
jetquery.fields.fieldType(Query.info.Model.Definition, primary_key)
else
void;
}
fn Map(QueryType: type, MergedRow: type, comptime primary_key: []const u8) type {
return struct {
id_array: std.ArrayList(PrimaryKey(QueryType, primary_key)),
id_map: IdMap(QueryType, primary_key),
aux_map: std.AutoHashMap(usize, MergedRow),
pub fn init(allocator: std.mem.Allocator) @This() {
const PK = PrimaryKey(QueryType, primary_key);
const IM = IdMap(QueryType, primary_key);
const AM = std.AutoHashMap(usize, MergedRow);
return .{
.id_array = std.ArrayList(PK).init(allocator),
.id_map = IM.init(allocator),
.aux_map = AM.init(allocator),
};
}
pub fn deinit(self: *@This()) void {
defer self.id_array.deinit();
defer self.id_map.deinit();
defer self.aux_map.deinit();
}
};
}

29
src/jetquery/Row.zig Normal file
View File

@ -0,0 +1,29 @@
/// A row returned in a `Result`.
const std = @import("std");
const jetquery = @import("../jetquery.zig");
/// A row returned in a `Result`.
allocator: std.mem.Allocator,
values: []const jetquery.Value,
columns: [][]const u8,
const Row = @This();
pub fn deinit(self: Row) void {
self.allocator.free(self.values);
}
/// Retrieve a typed value from a result row.
pub fn get(self: Row, T: type, column_name: []const u8) ?T {
for (self.columns, self.values) |column, value| {
if (std.mem.eql(u8, column_name, column)) return switch (T) {
[]const u8 => value.string,
usize => value.integer,
f64 => value.float,
bool => value.boolean,
else => @compileError("Unsupported type: " ++ @typeName(T)),
};
}
return null;
}

40
src/jetquery/Value.zig Normal file
View File

@ -0,0 +1,40 @@
const std = @import("std");
const jetquery = @import("../jetquery.zig");
/// A bound parameter (e.g. used in a where clause).
pub const Value = union(enum) {
string: []const u8,
integer: usize,
float: f64,
boolean: bool,
Null: void,
err: anyerror,
pub fn toSql(self: Value, buf: []u8, adapter: jetquery.adapters.Adapter, index: usize) ![]const u8 {
return try adapter.paramSql(buf, self, index);
// var stream = std.io.fixedBufferStream(buf);
// switch (self) {
// .string => |value| try writer.print("'{s}'", .{value}),
// .integer => |value| try writer.print("{}", .{value}),
// .float => |value| try writer.print("{d}", .{value}),
// .boolean => |value| try writer.print("{}", .{@as(u1, if (value) 1 else 0)}),
// .Null => try writer.print("NULL", .{}),
// .err => |err| return err,
// }
// return stream.getWritten();
}
pub fn eql(self: Value, other: Value) bool {
return switch (self) {
.string => |value| other == .string and std.mem.eql(u8, value, other.string),
.integer => |value| other == .integer and value == other.integer,
.float => |value| other == .float and value == other.float,
.boolean => |value| other == .boolean and value == other.boolean,
.Null => other == .Null,
.err => false,
};
}
pub fn PG() void {}
};

267
src/jetquery/adapters.zig Normal file
View File

@ -0,0 +1,267 @@
const std = @import("std");
const jetquery = @import("../jetquery.zig");
pub const PostgresqlAdapter = @import("adapters/PostgresqlAdapter.zig");
pub const NullAdapter = @import("adapters/NullAdapter.zig");
pub const Name = enum { postgresql, null };
pub fn Type(adapter: Name) type {
return switch (adapter) {
.postgresql => PostgresqlAdapter,
.null => NullAdapter,
};
}
pub const ConnectionOptions = struct { context: jetquery.Context };
pub fn Adapter(comptime adapter_name: Name, AdaptedRepo: type) type {
const Union = union(enum) {
postgresql: PostgresqlAdapter,
null: NullAdapter,
const Self = @This();
pub const name = adapter_name;
pub fn connect(self: *Self, options: ConnectionOptions) !jetquery.Connection {
return switch (comptime adapter_name) {
inline else => |tag| try @field(self, @tagName(tag)).connect(options),
};
}
pub fn release(self: *Self, connection: jetquery.Connection) void {
return switch (comptime adapter_name) {
inline else => |tag| @field(self, @tagName(tag)).release(connection),
};
}
/// Convert a column type to a database type suitable for the active adapter.
pub fn columnTypeSql(self: Self, comptime column: jetquery.schema.Column) []const u8 {
return switch (self) {
inline else => |adapter| @TypeOf(adapter).columnTypeSql(column),
};
}
/// Quote an identifier (e.g. a table name) suitable for the active adapter.
pub fn identifier(self: Self, comptime value: []const u8) []const u8 {
return switch (self) {
inline else => |adapter| @TypeOf(adapter).identifier(value),
};
}
/// Quote a column bound to a table suitable for the active adapter.
pub fn columnSql(
self: Self,
comptime column: jetquery.columns.Column,
) []const u8 {
return switch (self) {
inline else => |adapter| @TypeOf(adapter).columnSql(column),
};
}
/// SQL fragment used to indicate a primary key.
pub fn primaryKeySql(self: Self, comptime column: jetquery.schema.Column) []const u8 {
return switch (self) {
inline else => |adapter| @TypeOf(adapter).primaryKeySql(column),
};
}
/// SQL fragment used to indicate a column whose value cannot be `NULL`.
pub fn notNullSql(self: Self) []const u8 {
return switch (self) {
inline else => |adapter| @TypeOf(adapter).notNullSql(),
};
}
/// SQL representing a bind parameter, e.g. `$1`.
pub fn paramSql(self: Self, comptime index: usize) []const u8 {
return switch (self) {
inline else => |adapter| @TypeOf(adapter).paramSql(index),
};
}
/// Same as `paramSql` but writes to a buffer at runtime.
pub fn paramSqlBuf(self: Self, buf: []u8, index: usize) ![]const u8 {
return switch (self) {
inline else => |adapter| try @TypeOf(adapter).paramSqlBuf(buf, index),
};
}
/// SQL representing an array bind parameter with an `ANY` call, e.g. `ANY ($1)`.
pub fn anyParamSql(self: Self, comptime index: usize) []const u8 {
return switch (self) {
inline else => |adapter| @TypeOf(adapter).anyParamSql(index),
};
}
/// SQL representing an `ORDER BY` directive, e.g. `"foo" DESC`
pub fn orderSql(self: Self, comptime order_clause: jetquery.OrderClause) []const u8 {
return switch (self) {
inline else => |adapter| @TypeOf(adapter).orderSql(order_clause),
};
}
/// SQL fragment used when generating a `COUNT` column, e.g. `COUNT(*)`
pub fn countSql(
self: Self,
comptime distinct: ?[]const jetquery.columns.Column,
) []const u8 {
return switch (self) {
inline else => |adapter| @TypeOf(adapter).countSql(distinct),
};
}
/// SQL representing an inner join, e.g. `INNER JOIN "foo" ON "bar"."baz" = "foo"."baz"`
pub fn innerJoinSql(
self: Self,
Model: type,
JoinTable: type,
comptime relation_name: []const u8,
comptime options: JoinOptions,
) []const u8 {
return switch (self) {
inline else => |adapter| @TypeOf(adapter).innerJoinSql(
Model,
JoinTable,
relation_name,
options,
),
};
}
/// SQL representing an outer join, e.g. `LEFT OUTER JOIN "foo" ON "bar"."baz" = "foo"."baz"`
pub fn outerJoinSql(
self: Self,
Model: type,
JoinTable: type,
comptime relation_name: []const u8,
comptime options: JoinOptions,
) []const u8 {
return switch (self) {
inline else => |adapter| @TypeOf(adapter).outerJoinSql(
Model,
JoinTable,
relation_name,
options,
),
};
}
/// SQL fragment used as a `WHERE` clause when no clause has been applied by the user.
pub fn emptyWhereSQL(self: Self) []const u8 {
return switch (self) {
inline else => |adapter| @TypeOf(adapter).emptyWhereSQL(),
};
}
/// Automatically generate an index name from the given table name and columns. Fails if
/// generated name is too long for adapter's identifier length limit.
pub fn indexName(
self: Self,
comptime table_name: []const u8,
comptime column_names: []const []const u8,
) []const u8 {
return switch (self) {
inline else => |adapter| @TypeOf(adapter).indexName(table_name, column_names),
};
}
/// Generate SQL for creating an index with the active adapter.
pub fn createIndexSql(
self: Self,
comptime index_name: []const u8,
comptime table_name: []const u8,
comptime column_names: []const []const u8,
comptime options: AdaptedRepo.CreateIndexOptions,
) []const u8 {
return switch (self) {
inline else => |adapter| &@TypeOf(adapter).createIndexSql(
index_name,
table_name,
column_names,
options,
),
};
}
/// SQL fragment used when specifying a unique constraint.
pub fn uniqueColumnSql(self: Self) []const u8 {
return switch (self) {
inline else => |adapter| @TypeOf(adapter).uniqueColumnSql(),
};
}
/// SQL fragment used to denote a foreign key.
pub fn referenceSql(
self: Self,
comptime reference: jetquery.schema.Column.Reference,
) []const u8 {
return switch (self) {
inline else => |adapter| @TypeOf(adapter).referenceSql(reference),
};
}
/// Resolve an appropriate type for a given aggregate function (e.g. COUNT, MIN, MAX, etc.).
pub fn Aggregate(self: Self, context: jetquery.sql.FunctionContext) type {
return switch (self) {
inline else => |adapter| @TypeOf(adapter).Aggregate(context),
};
}
/// Return all metadata from the database needed to generate a schema file.
pub fn reflect(
self: *Self,
allocator: std.mem.Allocator,
repo: *AdaptedRepo,
) !jetquery.Reflection {
return switch (comptime adapter_name) {
inline else => |tag| try @field(self, @tagName(tag)).reflect(allocator, repo),
};
}
pub fn writeAddColumnSql(
self: Self,
comptime column: jetquery.schema.Column,
writer: anytype,
) !void {
if (column.timestamps) |timestamps| {
try timestamps.toSql(writer, self);
} else {
try writer.print(
\\{s}{s}{s}{s}{s}{s}
, .{
self.identifier(column.name),
if (column.primary_key)
""
else
self.columnTypeSql(column),
if (!column.primary_key and column.options.not_null)
self.notNullSql()
else
"",
if (column.primary_key) self.primaryKeySql(column) else "",
if (column.options.unique) self.uniqueColumnSql() else "",
if (column.options.reference) |reference|
self.referenceSql(reference)
else
"",
});
}
}
};
return Union;
}
pub const JoinOptions = struct {
foreign_key: ?[]const u8 = null,
primary_key: ?[]const u8 = null,
};
pub const test_adapter = Adapter(.postgresql){ .postgresql = .{
.options = undefined,
.pool = undefined,
.allocator = undefined,
.connected = undefined,
} };

View File

@ -0,0 +1,158 @@
const std = @import("std");
const jetquery = @import("../../jetquery.zig");
const fields = @import("../fields.zig");
const NullAdapter = @This();
const AdaptedRepo = jetquery.Repo(.null, struct {});
pub const Options = struct {};
pub const name: jetquery.adapters.Name = .null;
pub fn execute(self: *const NullAdapter, repo: *const AdaptedRepo, sql: []const u8, values: anytype, caller_info: ?jetquery.debug.CallerInfo) !jetquery.Result {
_ = self;
_ = repo;
_ = sql;
_ = values;
_ = caller_info;
return error.JetQueryNullAdapterError;
}
pub fn deinit(self: *const NullAdapter) void {
_ = self;
}
pub fn connect(self: *const NullAdapter, repo: *const AdaptedRepo) !jetquery.Connection {
_ = self;
_ = repo;
return error.JetQueryNullAdapterError;
}
pub fn release(
self: *const NullAdapter,
connection: jetquery.Connection,
) void {
// We don't return an error here because `release` is used in defers, but execute/connect
// will error before we get here in usual circumstances.
_ = self;
_ = connection;
}
pub fn columnTypeSql(comptime column: jetquery.schema.Column) []const u8 {
_ = column;
return "";
}
pub fn Aggregate(context: jetquery.sql.FunctionContext) type {
_ = context;
return usize;
}
pub fn identifier(comptime value: []const u8) []const u8 {
_ = value;
return "";
}
pub fn columnSql(Table: type, comptime column: jetquery.columnsColumn) []const u8 {
_ = Table;
_ = column;
return "";
}
pub fn primaryKeySql(comptime column: jetquery.schema.Column) []const u8 {
_ = column;
return "";
}
pub fn notNullSql() []const u8 {
return "";
}
pub fn countSql(comptime distinct: ?[]const jetquery.columns.Column) []const u8 {
_ = distinct;
return "";
}
pub fn paramSql(comptime index: usize) []const u8 {
_ = index;
return "";
}
pub fn anyParamSql(comptime index: usize) []const u8 {
_ = index;
return "";
}
pub fn innerJoinSql(
Table: type,
JoinTable: type,
comptime relation_name: []const u8,
comptime options: jetquery.adapters.JoinOptions,
) []const u8 {
_ = Table;
_ = JoinTable;
_ = relation_name;
_ = options;
return "";
}
pub fn outerJoinSql(
Table: type,
JoinTable: type,
comptime relation_name: []const u8,
comptime options: jetquery.adapters.JoinOptions,
) []const u8 {
_ = Table;
_ = JoinTable;
_ = relation_name;
_ = options;
return "";
}
pub fn emptyWhereSql() []const u8 {
return "";
}
pub fn indexName(
comptime table_name: []const u8,
comptime column_names: []const []const u8,
) [0]u8 {
_ = table_name;
_ = column_names;
return .{};
}
pub fn uniqueColumnSql() []const u8 {
return "";
}
pub fn referenceSql(comptime reference: jetquery.schema.Column.Reference) []const u8 {
_ = reference;
return "";
}
pub fn createIndexSql(
comptime index_name: []const u8,
comptime table_name: []const u8,
comptime column_names: []const []const u8,
comptime options: jetquery.CreateIndexOptions,
) [0]u8 {
_ = index_name;
_ = table_name;
_ = column_names;
_ = options;
return .{};
}
pub fn reflect(
self: *const NullAdapter,
allocator: std.mem.Allocator,
repo: *const AdaptedRepo,
) !jetquery.Reflection {
_ = allocator;
_ = self;
return .{ .allocator = repo.allocator, .tables = &.{}, .columns = &.{} };
}

View File

@ -0,0 +1,714 @@
const std = @import("std");
const builtin = @import("builtin");
const pg = @import("pg");
const jetquery = @import("../../jetquery.zig");
const PostgresqlAdapter = @This();
pool: *pg.Pool,
allocator: std.mem.Allocator,
options: Options,
connected: bool,
lazy_connect: bool = false,
pub const Count = i64;
pub const Average = i64;
pub const Sum = i64;
pub const Max = i32;
pub const Min = i32;
pub const max_identifier_len = 63;
pub const name: jetquery.adapters.Name = .postgresql;
pub fn Aggregate(comptime context: jetquery.sql.FunctionContext) type {
return switch (context) {
.min => Min,
.max => Max,
.count => Count,
.avg => Average,
.sum => Sum,
};
}
pub fn Result(AdaptedRepo: type) type {
return struct {
result: *pg.Result,
allocator: std.mem.Allocator,
connection: *pg.Conn,
caller_info: ?jetquery.debug.CallerInfo,
duration: i64,
repo: *AdaptedRepo,
const Self = @This();
pub fn deinit(self: *Self) void {
self.result.deinit();
}
pub fn drain(self: *Self) !void {
try self.result.drain();
}
pub fn next(self: *Self, query: anytype) !?@TypeOf(query).ResultType {
if (try self.result.next()) |row| {
var result_row: @TypeOf(query).ResultType = undefined;
inline for (@TypeOf(query).ColumnInfos) |column_info| {
if (column_info.relation) |relation| {
@field(
@field(result_row, relation.relation_name),
column_info.name,
) = try resolvedValue(self.allocator, column_info, &row);
} else {
@field(result_row, column_info.name) = try resolvedValue(
self.allocator,
column_info,
&row,
);
}
}
return result_row;
} else {
return null;
}
}
pub fn unary(self: *Self, T: type) !T {
// This error should really never happen if used in conjunction with (e.g.) a `COUNT`
// query, but we return an error to allow the host app (e.g. Jetzig) to handle it instead
// of panicking.
const row = try self.result.next() orelse return error.JetQueryMissingRowInUnaryQuery;
if (row.values.len < 1) return error.JetQueryMissingColumnInUnaryQuery;
return row.get(T, 0);
}
pub fn all(self: *Self, query: anytype) ![]@TypeOf(query).ResultType {
defer self.deinit();
var array = std.ArrayList(@TypeOf(query).ResultType).init(self.allocator);
while (try self.next(query)) |row| try array.append(row);
try self.drain();
return try array.toOwnedSlice();
}
pub fn execute(
self: *Self,
sql: []const u8,
values: anytype,
) !jetquery.Result(AdaptedRepo) {
return try self.connection.execute(sql, values, self.caller_info);
}
};
}
fn resolvedValue(
allocator: std.mem.Allocator,
column_info: jetquery.sql.ColumnInfo,
row: *const pg.Row,
) !column_info.type {
return switch (column_info.type) {
// TODO: pg.Numeric, pg.Cidr
u8,
?u8,
i16,
?i16,
i32,
?i32,
i64,
?i64,
f32,
?f32,
[]u8,
?[]u8,
bool,
?bool,
[]const u8,
?[]const u8,
=> |T| try maybeDupe(allocator, T, row.get(T, column_info.index)),
jetquery.jetcommon.types.DateTime => |T| try T.fromUnix(
row.get(i64, column_info.index),
.microseconds,
),
else => |T| @compileError("Unsupported type: " ++ @typeName(T)),
};
}
fn maybeDupe(allocator: std.mem.Allocator, T: type, value: T) !T {
return switch (T) {
[]const u8 => try allocator.dupe(u8, value),
?[]const u8 => if (value) |val| try allocator.dupe(u8, val) else null,
else => value,
};
}
pub const Options = struct {
database: ?[]const u8 = null,
username: ?[]const u8 = null,
password: ?[]const u8 = null,
hostname: ?[]const u8 = null,
port: ?u16 = null,
pool_size: ?u16 = null,
timeout: ?u32 = null,
pub fn defaultValue(T: type, comptime field_name: []const u8) T {
const tag = std.enums.nameCast(std.meta.FieldEnum(Options), field_name);
return switch (tag) {
.database, .username, .password => null,
.hostname => "localhost",
.port => 5432,
.pool_size => 8,
.timeout => 10_000,
};
}
};
/// Initialize a new PostgreSQL adapter and connection pool.
pub fn init(allocator: std.mem.Allocator, options: Options, lazy_connect: bool) !PostgresqlAdapter {
if (lazy_connect) return .{
.allocator = allocator,
.options = options,
.pool = undefined,
.lazy_connect = true,
.connected = false,
};
return .{
.allocator = allocator,
.options = options,
.pool = try initPool(allocator, options),
.connected = true,
};
}
/// Close connections and free resources.
pub fn deinit(self: *PostgresqlAdapter) void {
self.pool.deinit();
}
pub const Connection = struct {
connection: *pg.Conn,
options: jetquery.adapters.ConnectionOptions,
pub fn execute(
self: Connection,
sql: []const u8,
values: anytype,
caller_info: ?jetquery.debug.CallerInfo,
repo: anytype,
) !jetquery.Result(@TypeOf(repo.*)) {
const start_time = std.time.nanoTimestamp();
const result = self.connection.queryOpts(sql, values, .{}) catch |err| {
try self.errorCallback(err, sql, repo, caller_info);
return err;
};
const duration: i64 = @intCast(std.time.nanoTimestamp() - start_time);
try repo.eventCallback(.{
.sql = sql,
.caller_info = caller_info,
.duration = duration,
.context = self.options.context,
});
return .{
.postgresql = .{
.allocator = repo.allocator,
.result = result,
.connection = self.connection,
.caller_info = caller_info,
.duration = duration,
.repo = repo,
},
};
}
/// Execute a query with runtime binding. Used internally by `Repo.save`. This API is not
/// intended for public use.
pub fn executeRuntimeBind(
self: Connection,
sql: []const u8,
values: anytype,
comptime Args: type,
args: Args,
field_states: []const jetquery.sql.FieldState,
caller_info: ?jetquery.debug.CallerInfo,
repo: anytype,
) !jetquery.Result(@TypeOf(repo.*)) {
const start_time = std.time.nanoTimestamp();
var stmt = try pg.Stmt.init(self.connection, .{});
errdefer stmt.deinit();
stmt.prepare(sql) catch |err| {
try self.errorCallback(err, sql, repo, caller_info);
return err;
};
inline for (values) |value| {
try stmt.bind(value);
}
inline for (std.meta.fields(Args), 0..) |field, index| {
if (field_states[index].modified) try stmt.bind(@field(args, field.name));
}
const result = stmt.execute() catch |err| {
try self.errorCallback(err, sql, repo, caller_info);
return err;
};
const duration: i64 = @intCast(std.time.nanoTimestamp() - start_time);
try repo.eventCallback(.{
.sql = sql,
.caller_info = caller_info,
.duration = duration,
.context = self.options.context,
});
return .{
.postgresql = .{
.allocator = repo.allocator,
.result = result,
.connection = self.connection,
.caller_info = caller_info,
.duration = duration,
.repo = repo,
},
};
}
pub fn release(self: Connection) void {
self.connection.release();
}
fn errorCallback(
self: Connection,
err: anyerror,
sql: []const u8,
repo: anytype,
caller_info: ?jetquery.debug.CallerInfo,
) !void {
if (self.connection.err) |connection_error| {
try repo.eventCallback(.{
.sql = sql,
.err = .{ .err = err, .message = connection_error.message },
.status = .fail,
.caller_info = caller_info,
.context = self.options.context,
});
} else {
try repo.eventCallback(.{
.sql = sql,
.err = .{ .err = err, .message = "[unknown error]" },
.status = .fail,
.caller_info = caller_info,
.context = self.options.context,
});
}
}
};
pub fn connect(
self: *PostgresqlAdapter,
options: jetquery.adapters.ConnectionOptions,
) !jetquery.Connection {
if (self.lazy_connect) self.pool = try initPool(self.allocator, self.options);
return .{ .postgresql = .{ .options = options, .connection = try self.pool.acquire() } };
}
pub fn release(self: *PostgresqlAdapter, connection: jetquery.Connection) void {
self.pool.release(connection.postgresql.connection);
}
/// Output column type as SQL.
pub fn columnTypeSql(comptime column: jetquery.schema.Column) []const u8 {
return switch (column.type) {
.string => " VARCHAR" ++ std.fmt.comptimePrint("({})", .{column.options.length orelse 255}),
.integer => " INTEGER",
.boolean => " BOOLEAN",
.float => " REAL",
.decimal => " NUMERIC",
.datetime => " TIMESTAMP",
.text => " TEXT",
};
}
/// Output quoted identifier.
pub fn identifier(comptime value: []const u8) []const u8 {
return std.fmt.comptimePrint(
\\"{s}"
, .{value});
}
/// SQL fragment used to represent a column bound to a table, e.g. `"foo"."bar"`
pub fn columnSql(comptime column: jetquery.columns.Column) []const u8 {
return if (column.function) |function|
std.fmt.comptimePrint(
\\{s}("{s}"."{s}")
, .{
switch (function) {
.min => "MIN",
.max => "MAX",
.count => "COUNT",
.avg => "AVG",
.sum => "SUM",
},
column.table.name,
column.name,
})
else if (column.sql) |sql|
sql
else
std.fmt.comptimePrint(
\\"{s}"."{s}"
, .{ column.table.name, column.name });
}
/// SQL fragment used to indicate a primary key.
pub fn primaryKeySql(comptime column: jetquery.schema.Column) []const u8 {
return switch (column.type) {
.integer => " SERIAL PRIMARY KEY",
else => comptime columnTypeSql(column) ++ " PRIMARY KEY",
};
}
/// SQL fragment used to indicate a column whose value cannot be `NULL`.
pub fn notNullSql() []const u8 {
return " NOT NULL";
}
/// SQL representing a bind parameter, e.g. `$1`.
pub fn paramSql(comptime index: usize) []const u8 {
return std.fmt.comptimePrint("${}", .{index + 1});
}
/// SQL representing a bind parameter, e.g. `$1`.
pub fn paramSqlBuf(buf: []u8, index: usize) ![]const u8 {
return try std.fmt.bufPrint(buf, "${}", .{index + 1});
}
/// SQL representing an array bind parameter with an `ANY` call, e.g. `ANY ($1)`.
pub fn anyParamSql(comptime index: usize) []const u8 {
return std.fmt.comptimePrint("ANY (${})", .{index + 1});
}
pub fn orderSql(comptime order_clause: jetquery.sql.OrderClause) []const u8 {
const direction = switch (order_clause.direction) {
.ascending, .asc => "ASC",
.descending, .desc => "DESC",
};
return std.fmt.comptimePrint(
"{s} {s}",
.{ columnSql(order_clause.column), direction },
);
}
pub fn countSql(comptime distinct: ?[]const jetquery.columns.Column) []const u8 {
// TODO: Move some of this back into `sql.zig`.
return if (comptime distinct) |distinct_columns| blk: {
const template = "{s}.{s}{s}";
var size: usize = 0;
for (distinct_columns, 0..) |column, index| {
size += std.fmt.count(
template,
.{
identifier(column.table.name),
identifier(column.name),
if (index + 1 < distinct_columns.len) ", " else "",
},
);
}
var buf: [size]u8 = undefined;
var cursor: usize = 0;
for (distinct_columns, 0..) |column, index| {
const column_sql = std.fmt.comptimePrint(
template,
.{
identifier(column.table.name),
identifier(column.name),
if (index + 1 < distinct_columns.len) ", " else "",
},
);
@memcpy(buf[cursor .. cursor + column_sql.len], column_sql);
cursor += column_sql.len;
}
break :blk std.fmt.comptimePrint("COUNT(DISTINCT({s}))", .{buf});
} else "COUNT(*)";
}
pub fn innerJoinSql(
Table: type,
JoinTable: type,
comptime relation_name: []const u8,
comptime options: jetquery.adapters.JoinOptions,
) []const u8 {
const foreign_key = options.foreign_key orelse relation_name ++ "_id";
const primary_key = options.primary_key orelse "id";
return std.fmt.comptimePrint(
\\ INNER JOIN "{s}" ON "{s}"."{s}" = "{s}"."{s}"
,
.{
JoinTable.name,
Table.name,
foreign_key,
JoinTable.name,
primary_key,
},
);
}
pub fn outerJoinSql(
Table: type,
JoinTable: type,
comptime relation_name: []const u8,
comptime options: jetquery.adapters.JoinOptions,
) []const u8 {
const foreign_key = options.foreign_key orelse relation_name ++ "_id";
const primary_key = options.primary_key orelse "id";
return std.fmt.comptimePrint(
\\ LEFT OUTER JOIN "{s}" ON "{s}"."{s}" = "{s}"."{s}"
,
.{
JoinTable.name,
Table.name,
foreign_key,
JoinTable.name,
primary_key,
},
);
}
pub fn emptyWhereSql() []const u8 {
return "(1 = 1)";
}
pub fn indexName(
comptime table_name: []const u8,
comptime column_names: []const []const u8,
) *const [indexNameSize(table_name, column_names)]u8 {
comptime {
var buf: [indexNameSize(table_name, column_names)]u8 = undefined;
const prefix = std.fmt.comptimePrint("index_{s}_", .{table_name});
@memcpy(buf[0..prefix.len], prefix);
var cursor: usize = prefix.len;
for (column_names, 0..) |column_name, index| {
const separator = if (index + 1 < column_names.len) "_" else "";
const column_suffix = std.fmt.comptimePrint("{s}{s}", .{ column_name, separator });
@memcpy(buf[cursor .. cursor + column_suffix.len], column_suffix);
cursor += column_suffix.len;
}
const final = buf;
return &final;
}
}
fn indexNameSize(comptime table_name: []const u8, comptime column_names: []const []const u8) usize {
comptime {
var size: usize = 0;
size += std.fmt.comptimePrint("index_{s}_", .{table_name}).len;
for (column_names, 0..) |column_name, index| {
const separator = if (index + 1 < column_names.len) "_" else "";
size += std.fmt.comptimePrint("{s}{s}", .{ column_name, separator }).len;
}
if (size > max_identifier_len) {
@compileError(
std.fmt.comptimePrint(
"Generated index name length {} longer than {} characters. Specify `.index_name` to manually set a name for this index.",
.{ size, max_identifier_len },
),
);
}
return size;
}
}
pub fn createIndexSql(
comptime index_name: []const u8,
comptime table_name: []const u8,
comptime column_names: []const []const u8,
comptime options: jetquery.CreateIndexOptions,
) *const [createIndexSqlSize(index_name, table_name, column_names, options)]u8 {
comptime {
var buf: [createIndexSqlSize(index_name, table_name, column_names, options)]u8 = undefined;
const statement = std.fmt.comptimePrint(
"CREATE {s}INDEX{s} {s} ON {s} (",
.{
if (options.unique) "UNIQUE " else "",
if (options.if_not_exists) " IF NOT EXISTS" else "",
identifier(index_name),
identifier(table_name),
},
);
@memcpy(buf[0..statement.len], statement);
var cursor: usize = statement.len;
for (column_names, 0..) |column_name, index| {
const separator = if (index + 1 < column_names.len) ", " else "";
const column = std.fmt.comptimePrint("{s}{s}", .{ column_name, separator });
@memcpy(buf[cursor .. cursor + column.len], column);
cursor += column.len;
}
buf[cursor] = ')';
const final = buf;
return &final;
}
}
fn createIndexSqlSize(
comptime index_name: []const u8,
comptime table_name: []const u8,
comptime column_names: []const []const u8,
comptime options: jetquery.CreateIndexOptions,
) usize {
comptime {
var size: usize = 0;
size += std.fmt.comptimePrint(
"CREATE {s}INDEX{s} {s} ON {s} (",
.{
if (options.unique) "UNIQUE " else "",
if (options.if_not_exists) " IF NOT EXISTS" else "",
identifier(index_name),
identifier(table_name),
},
).len;
for (column_names, 0..) |column_name, index| {
const separator = if (index + 1 < column_names.len) ", " else "";
size += std.fmt.comptimePrint("{s}{s}", .{ column_name, separator }).len;
}
size += ")".len;
return size;
}
}
pub fn uniqueColumnSql() []const u8 {
return " UNIQUE";
}
pub fn referenceSql(comptime reference: jetquery.schema.Column.Reference) []const u8 {
return std.fmt.comptimePrint(
" REFERENCES {s}({s})",
.{ comptime identifier(reference[0]), comptime identifier(reference[1]) },
);
}
pub fn reflect(
self: *PostgresqlAdapter,
allocator: std.mem.Allocator,
repo: anytype,
) !jetquery.Reflection {
const tables = try self.reflectTables(allocator, repo);
const columns = try self.reflectColumns(allocator, repo);
return .{ .allocator = self.allocator, .tables = tables, .columns = columns };
}
pub fn reflectTables(
self: *PostgresqlAdapter,
allocator: std.mem.Allocator,
repo: anytype,
) ![]const jetquery.Reflection.TableInfo {
_ = self;
const sql =
\\SELECT "table_name" FROM "information_schema"."tables" WHERE "table_schema" = 'public' AND "table_name" <> 'jetquery_migrations' ORDER BY "table_name"
;
var result = try repo.executeSql(sql, .{});
defer result.deinit();
var tables = std.ArrayList(jetquery.Reflection.TableInfo).init(allocator);
while (try result.postgresql.result.next()) |row| {
try tables.append(.{
.name = try allocator.dupe(u8, row.get([]const u8, 0)),
});
}
try result.drain();
return try tables.toOwnedSlice();
}
pub fn reflectColumns(
_: *PostgresqlAdapter,
allocator: std.mem.Allocator,
repo: anytype,
) ![]const jetquery.Reflection.ColumnInfo {
const sql =
\\SELECT "table_name", "column_name", "data_type", "is_nullable" FROM "information_schema"."columns" WHERE "table_schema" = 'public' ORDER BY "table_name", "ordinal_position"
;
var result = try repo.executeSql(sql, .{});
defer result.deinit();
var columns = std.ArrayList(jetquery.Reflection.ColumnInfo).init(allocator);
while (try result.postgresql.result.next()) |row| {
try columns.append(.{
.table = try allocator.dupe(u8, row.get([]const u8, 0)),
.name = try allocator.dupe(u8, row.get([]const u8, 1)),
.type = translateColumnType(row.get([]const u8, 2)),
.null = std.mem.eql(u8, row.get([]const u8, 3), "YES"),
});
}
try result.drain();
return try columns.toOwnedSlice();
}
fn translateColumnType(column_name: []const u8) jetquery.schema.Column.Type {
// TODO
const types = std.StaticStringMap(jetquery.schema.Column.Type).initComptime(.{
.{ "integer", jetquery.schema.Column.Type.integer },
.{ "real", jetquery.schema.Column.Type.float },
.{ "boolean", jetquery.schema.Column.Type.boolean },
.{ "numeric", jetquery.schema.Column.Type.decimal },
.{ "character varying", jetquery.schema.Column.Type.string },
.{ "text", jetquery.schema.Column.Type.text },
.{ "timestamp without time zone", jetquery.schema.Column.Type.datetime },
.{ "timestamp with time zone", jetquery.schema.Column.Type.datetime },
});
return types.get(column_name) orelse {
std.log.err("Unsupported column type: `{s}`\n", .{column_name});
unreachable;
};
}
fn initPool(allocator: std.mem.Allocator, options: Options) !*pg.Pool {
return try pg.Pool.init(allocator, .{
.size = options.pool_size.?,
.connect = .{
.port = options.port.?,
.host = options.hostname.?,
},
.auth = .{
.username = options.username orelse return configError("username"),
.database = options.database orelse return configError("database"),
.password = options.password orelse return configError("password"),
.timeout = options.timeout.?,
},
});
}
fn configError(comptime config_field: []const u8) error{JetQueryConfigError} {
const template = "Missing database configuration value for: `{s}`. " ++
"Configure in JetQuery config file or `JETQUERY_{s}`.";
const message = comptime blk: {
var buf: [config_field.len]u8 = undefined;
break :blk std.fmt.comptimePrint(
template,
.{ config_field, std.ascii.upperString(&buf, config_field) },
);
};
if (builtin.is_test) { // https://github.com/ziglang/zig/issues/5738
std.log.warn(message, .{});
} else {
std.log.err(message, .{});
}
return error.JetQueryConfigError;
}

162
src/jetquery/coercion.zig Normal file
View File

@ -0,0 +1,162 @@
const std = @import("std");
const jetcommon = @import("jetcommon");
const fields = @import("fields.zig");
pub fn coerce(
Table: type,
field_info: fields.FieldInfo,
value: anytype,
) CoercedValue(fields.ColumnType(Table, field_info), @TypeOf(value)) {
switch (field_info.context) {
.limit, .offset => return switch (@typeInfo(@TypeOf(value))) {
.int, .comptime_int => .{ .value = value },
else => coerceDelegate(usize, value),
},
else => {},
}
const T = fields.ColumnType(Table, field_info);
if (T == jetcommon.types.DateTime) return value.microseconds;
return switch (@typeInfo(@TypeOf(value))) {
.null => .{ .value = null },
.int, .comptime_int => switch (@typeInfo(T)) {
.int => .{ .value = @intCast(value) },
else => coerceDelegate(T, value),
},
.float, .comptime_float => switch (@typeInfo(T)) {
.float => .{ .value = @floatCast(value) },
else => coerceDelegate(T, value),
},
.pointer => |info| switch (@typeInfo(T)) {
.int => switch (@typeInfo(info.child)) {
.int => switch (info.size) {
// FIXME: For now we get away with this because postgres does not have a u8
// type but we may need a better way to identify strings if another database
// adapter does support u8.
.Slice => if (@TypeOf(value) == []const u8)
coerceInt(T, value)
else
.{ .value = value },
else => .{ .value = value },
},
else => if (comptime canCoerceDelegate(info.child))
coerceDelegate(T, value.*)
else
coerceInt(T, value),
},
.float => switch (@typeInfo(info.child)) {
.float => switch (info.size) {
.Slice => .{ .value = value },
else => .{ .value = value },
},
else => if (comptime canCoerceDelegate(info.child))
coerceDelegate(T, value.*)
else
coerceFloat(T, value),
},
.bool => switch (@typeInfo(info.child)) {
.bool => switch (info.size) {
.Slice => .{ .value = value },
else => .{ .value = value },
},
else => if (comptime canCoerceDelegate(info.child))
coerceDelegate(T, value.*)
else
coerceBool(T, value),
},
.pointer => if (comptime canCoerceDelegate(info.child))
coerceDelegate(T, value.*)
else
.{ .value = value }, // Let Zig compiler figure it out
else => if (comptime canCoerceDelegate(info.child))
coerceDelegate(T, value.*)
else
@compileError("Incompatible types: `" ++
@typeName(T) ++ "` and `" ++ @typeName(info.child) ++ "`"),
},
else => coerceDelegate(T, value),
};
}
// Call `toJetQuery` with a given type and an allocator on the given arg field. Although
// this function expects a return value not specific to JetQuery, the intention is that
// arbitrary types can implement `toJetQuery` if the author wants them to be used with
// JetQuery, otherwise a typical Zig compile error will occur. This feature is used by
// Zmpl for converting Zmpl Values, allowing e.g. request params in Jetzig to be used as
// JetQuery whereclause/etc. params.
pub fn coerceDelegate(Target: type, value: anytype) CoercedValue(Target, @TypeOf(value)) {
const Source = @TypeOf(value);
if (comptime canCoerceDelegate(Source)) {
const coerced = value.toJetQuery(Target) catch |err| {
return .{ .err = err };
};
return .{ .value = coerced, .err = null };
} else {
@compileError("Incompatible types: `" ++ @typeName(Target) ++ "` and `" ++ @typeName(Source) ++ "`");
}
}
pub fn canCoerceDelegate(T: type) bool {
return switch (@typeInfo(T)) {
.@"struct", .@"union" => std.meta.hasFn(T, "toJetQuery"),
.pointer => |info| std.meta.hasFn(info.child, "toJetQuery"),
else => false,
};
}
pub fn CoercedValue(Target: type, Source: type) type {
const T = switch (@typeInfo(Source)) {
.null => @TypeOf(null),
.pointer => |info| if (info.child == Target and info.size == .Slice)
[]const Target
else
Target,
else => Target,
};
return struct {
value: T = undefined, // Never used if `err` is present
err: ?anyerror = null,
};
}
fn coerceInt(T: type, value: []const u8) CoercedValue(T, @TypeOf(value)) {
const coerced = std.fmt.parseInt(T, value, 10) catch |err| {
return .{
.err = switch (err) {
error.InvalidCharacter, error.Overflow => error.JetQueryInvalidIntegerString,
},
};
};
return .{ .value = coerced };
}
fn coerceFloat(T: type, value: []const u8) CoercedValue(T, @TypeOf(value)) {
const coerced = std.fmt.parseFloat(T, value) catch |err| {
return .{
.err = switch (err) {
error.InvalidCharacter => error.JetQueryInvalidFloatString,
},
};
};
return .{ .value = coerced };
}
fn coerceBool(T: type, value: []const u8) CoercedValue(T, @TypeOf(value)) {
if (value.len != 1) return .{ .err = error.JetQueryInvalidBooleanString };
const maybe_boolean = switch (value[0]) {
'1' => true,
'0' => false,
else => null,
};
return if (maybe_boolean) |boolean|
.{ .value = boolean }
else
.{ .err = error.JetQueryInvalidBooleanString };
}

197
src/jetquery/columns.zig Normal file
View File

@ -0,0 +1,197 @@
const std = @import("std");
const sql = @import("sql.zig");
pub const Column = struct {
name: []const u8,
type: type,
table: type,
function: ?sql.FunctionContext = null,
alias: ?[]const u8 = null,
sql: ?[]const u8 = null,
pub fn ResultType(comptime self: Column, Adapter: type) type {
return if (self.function) |function| Adapter.Aggregate(function) else self.type;
}
pub fn as(comptime self: Column, comptime alias: anytype) Column {
var column = self;
column.alias = @tagName(alias);
return column;
}
};
pub fn translate(
Table: type,
relations: []const type,
comptime maybe_args: anytype,
) [sizeOf(Table, relations, maybe_args)]Column {
comptime {
const args = if (@TypeOf(maybe_args) == @TypeOf(null)) return .{} else maybe_args;
if (args.len == 0) return Table.columns();
var fields: [sizeOf(Table, relations, args)]Column = undefined;
var index: usize = 0;
for (args) |arg| {
if (@TypeOf(arg) == Column) {
if (arg.alias == null) @compileError(std.fmt.comptimePrint(
\\Custom SQL columns must be aliased. Call `as("...")` to specify an alias. Failed for column `{?s}`,
,
.{arg.sql},
));
fields[index] = arg;
index += 1;
continue;
}
switch (@typeInfo(@TypeOf(arg))) {
.enum_literal, .@"enum" => {
fields[index] = primaryColumn(Table, @tagName(arg));
index += 1;
},
.@"struct" => {
const count = nestedColumns(Table, relations, arg, undefined, true);
var buf: [count]Column = undefined;
const nested = nestedColumns(Table, relations, arg, &buf, false);
@memcpy(fields[index .. index + count], nested);
index += count;
},
.type => {
if (@hasField(arg, "__jetquery_function")) {
const function = (arg{}).__jetquery_function;
var column = primaryColumn(Table, function.column_name);
column.function = function.context;
column.alias = function.alias;
fields[index] = column;
index += 1;
} else {
@compileError("Unexpected type in columns: `" ++ @typeName(arg) ++ "`");
}
},
else => |tag| {
@compileError(
"Expected [enum, enum_literal, struct] column arguments, found: `" ++ @tagName(tag) ++ "`",
);
},
}
}
return fields;
}
}
fn sizeOf(
Table: type,
comptime relations: []const type,
comptime maybe_args: anytype,
) usize {
comptime {
const args = if (@TypeOf(maybe_args) == @TypeOf(null)) return 0 else maybe_args;
if (args.len == 0) return Table.columns().len;
var size: usize = 0;
for (args) |arg| {
if (@TypeOf(arg) == sql.Function) {
_ = primaryColumn(Table, arg.column_name); // validate column
size += 1;
continue;
}
if (@TypeOf(arg) == Column) {
size += 1;
continue;
}
switch (@typeInfo(@TypeOf(arg))) {
.enum_literal, .@"enum" => {
_ = primaryColumn(Table, @tagName(arg)); // validate column
size += 1;
},
.pointer => {
_ = primaryColumn(Table, arg); // validate column
size += 1;
},
.@"struct" => {
size += nestedColumns(Table, relations, arg, undefined, true);
},
.type => {
if (@hasField(arg, "__jetquery_function")) {
size += 1;
} else {
@compileError(
"Unsupported type in column arguments: `" ++ @typeName(arg) ++ "`",
);
}
},
else => |tag| {
@compileError(
"Expected [enum, enum_literal, []const u8, struct] column arguments, found: `" ++ @tagName(tag) ++ "`",
);
},
}
}
return size;
}
}
fn primaryColumn(
Table: type,
comptime name: []const u8,
) Column {
comptime {
for (Table.columns()) |column| {
if (std.mem.eql(u8, column.name, name)) return .{
.table = Table,
.name = name,
.type = column.type,
};
}
@compileError(std.fmt.comptimePrint(
"Failed matching column `{s}` in Schema for `{s}`.",
.{ name, Table.name },
));
}
}
fn nestedColumns(
Table: type,
relations: []const type,
comptime arg: anytype,
buf: []Column,
comptime count: bool,
) if (count) usize else []const Column {
var index: usize = 0;
for (std.meta.fields(@TypeOf(arg))) |field| {
for (relations) |Relation| {
if (std.mem.eql(u8, field.name, Relation.relation_name)) {
for (@field(arg, field.name)) |nested_arg| {
for (Relation.Source.columns()) |column| {
if (std.mem.eql(u8, column.name, @tagName(nested_arg))) {
if (!count) {
buf[index] = .{
.name = @tagName(nested_arg),
.table = Relation.Source,
.type = column.type,
};
}
index += 1;
break;
}
} else @compileError(std.fmt.comptimePrint(
"Failed matching column `{s}.{s}` .",
.{ field.name, @tagName(nested_arg) },
));
}
break;
}
} else @compileError(std.fmt.comptimePrint(
"Failed matching relation `{s}` for table `{s}`.",
.{ field.name, Table.name },
));
}
return if (count) index else buf[0..];
}

36
src/jetquery/debug.zig Normal file
View File

@ -0,0 +1,36 @@
const std = @import("std");
const builtin = @import("builtin");
pub const CallerInfo = struct {
debug_info: *std.debug.SelfInfo,
file_name: []const u8,
line_number: u64,
pub fn deinit(self: CallerInfo) void {
self.debug_info.allocator.free(self.file_name);
}
};
pub fn getCallerInfo(address: usize) !?CallerInfo {
if (builtin.mode != .Debug) return null;
const debug_info = try std.debug.getSelfDebugInfo();
const module = debug_info.getModuleForAddress(address) catch |err| switch (err) {
error.MissingDebugInfo, error.InvalidDebugInfo => return null,
else => return err,
};
const symbol_info = module.getSymbolAtAddress(debug_info.allocator, address) catch |err| switch (err) {
error.MissingDebugInfo, error.InvalidDebugInfo => return null,
else => return err,
};
return if (symbol_info.source_location) |source_location|
.{
.debug_info = debug_info,
.file_name = source_location.file_name,
.line_number = source_location.line,
}
else
null;
}

View File

@ -0,0 +1,2 @@
pub const created_at = "created_at";
pub const updated_at = "updated_at";

65
src/jetquery/events.zig Normal file
View File

@ -0,0 +1,65 @@
const std = @import("std");
const jetquery = @import("../jetquery.zig");
/// An event triggered by executing a query. When creating a `Repo`, use the option
/// `eventCallback` to specify a function that will receive an `Event` for each query execution.
/// Otherwise, `defaultCallback` will be invoked instead.
pub const Event = struct {
// TODO: Make this a union for failed/successful queries etc.
const Error = struct {
message: []const u8,
err: anyerror,
};
context: jetquery.Context = .query,
level: enum { DEBUG, INFO, WARN, ERROR } = .INFO,
message: ?[]const u8 = null,
sql: ?[]const u8 = null,
status: enum { success, fail } = .success,
err: ?Error = null,
caller_info: ?jetquery.debug.CallerInfo = null,
duration: ?i64 = null,
};
pub fn defaultCallback(event: Event) !void {
if (event.caller_info) |info| {
if (event.context == .query) {
const allocator = info.debug_info.allocator;
const cwd = try std.fs.cwd().realpathAlloc(allocator, ".");
defer allocator.free(cwd);
const relative = try std.fs.path.relative(allocator, cwd, info.file_name);
defer allocator.free(relative);
std.debug.print("[{s}:{}] ", .{ relative, info.line_number });
}
}
if (event.err) |err| {
std.debug.print(
\\
\\/
\\| Query:
\\| {s}
\\| Error:
\\| {s}: {s}
\\\
\\
, .{ event.sql orelse "", @errorName(err.err), err.message });
} else {
var buf: [32]u8 = undefined;
const formatted_duration = if (event.duration) |duration|
try std.fmt.bufPrint(&buf, " [{}]", .{std.fmt.fmtDurationSigned(duration)})
else
"";
std.debug.print("{s}{s}{s}{s}{s}", .{
event.message orelse "",
if (event.message) |_| "\n" else "",
event.sql orelse "",
formatted_duration,
if (event.sql) |_| "\n" else "",
});
}
}

173
src/jetquery/fields.zig Normal file
View File

@ -0,0 +1,173 @@
const std = @import("std");
const jetcommon = @import("jetcommon");
const Where = @import("sql/Where.zig");
pub const FieldContext = enum { where, update, insert, limit, offset, order, none };
pub const FieldInfo = struct {
info: std.builtin.Type.StructField,
name: []const u8,
Table: type,
context: FieldContext,
};
pub fn fieldInfos(
Adapter: type,
Table: type,
relations: []const type,
T: type,
comptime context: FieldContext,
) [Where.tree(Adapter, Table, relations, T, context, 0).values_count]FieldInfo {
comptime {
const tree = Where.tree(Adapter, Table, relations, T, context, 0);
var value_fields: [tree.values_count]FieldInfo = undefined;
for (std.meta.fields(tree.ValuesTuple), tree.values_fields, 0..) |tuple_field, value_field, index| {
value_fields[index] = fieldInfo(tuple_field, value_field.Table, value_field.name, context);
}
return value_fields;
}
}
pub fn fieldInfo(
comptime field: std.builtin.Type.StructField,
Table: type,
comptime name: []const u8,
comptime context: FieldContext,
) FieldInfo {
return .{ .info = field, .context = context, .name = name, .Table = Table };
}
pub fn FieldValues(Table: type, relations: []const type, comptime fields: []const FieldInfo) type {
_ = Table;
_ = relations;
var new_fields: [fields.len]std.builtin.Type.StructField = undefined;
for (fields, 0..) |field, index| {
new_fields[index] = .{
.name = std.fmt.comptimePrint("{}", .{index}),
.type = field.info.type,
.default_value = null,
.is_comptime = false,
.alignment = @alignOf(field.info.type),
};
}
return @Type(.{
.@"struct" = .{
.layout = .auto,
.fields = &new_fields,
.decls = &.{},
.is_tuple = true,
},
});
}
pub fn ColumnType(Table: type, comptime field_info: FieldInfo) type {
switch (field_info.context) {
.limit, .offset => return usize,
.where, .update, .insert, .order, .none => {},
}
if (comptime @hasField(Table.Definition, field_info.name)) {
const FT = fieldType(Table.Definition, field_info.name);
if (FT == jetcommon.types.DateTime) return i64 else return FT;
} else {
// We only arrive here when we process triplets, e.g.
// `.{ .foo, .lt_eql, 100 }`
// But we coerce to the other side of the triplet and only use this type as a fallback in
// the specific case that two values (i.e. not a column or SQL function) are used on both
// sides of the triplet, e.g.:
// `.{ 1, .lt, 100 }`
// Without a know coercion target the only thing we can do here is use the value's type
// and assume the database adapter will know what to do with it, otherwise we get a
// compile error and the user has to do an explicit cast. This is all an edge case of an
// edge case.
return field_info.info.type;
}
}
pub fn structField(comptime name: []const u8, T: type) std.builtin.Type.StructField {
comptime {
return .{
.name = name ++ "",
.type = T,
.default_value = null,
.is_comptime = false,
.alignment = @alignOf(T),
};
}
}
pub fn structFieldDefault(comptime name: []const u8, default: anytype) std.builtin.Type.StructField {
comptime {
return .{
.name = name ++ "",
.type = @TypeOf(default),
.default_value = &default,
.is_comptime = false,
.alignment = @alignOf(@TypeOf(default)),
};
}
}
pub fn structFieldComptime(
comptime name: []const u8,
comptime default: anytype,
) std.builtin.Type.StructField {
comptime {
return .{
.name = name ++ "",
.type = @TypeOf(default),
.default_value = &default,
.is_comptime = true,
.alignment = @alignOf(@TypeOf(default)),
};
}
}
pub fn structType(comptime fields: []const std.builtin.Type.StructField) type {
return @Type(.{
.@"struct" = .{
.layout = .auto,
.fields = fields,
.decls = &.{},
.is_tuple = false,
},
});
}
pub fn fieldType(T: type, comptime name: []const u8) type {
const tag = std.enums.nameCast(std.meta.FieldEnum(T), name);
const FT = std.meta.fieldInfo(T, tag).type;
return switch (@typeInfo(FT)) {
.optional => |optional| optional.child,
else => FT,
};
}
// We need to ensure that we don't store a comptime value otherwise our
// entire data structure needs to be comptime-known. This only occurs when a
// user does one of the following:
//
// Triplet where both sides are values:
// `.where(.{ 1, .eql, 1 })`
// SQL string with mixed runtime and comp-time args:
// `.where(.{ "foo = ?", .{ 1, a_runtime_value } })`
//
// Otherwise we have a target type to coerce to, either from the other side of the triplet, or
// whatever the column type is defined in the schema for column values.
pub fn ComptimeErasedType(T: type) type {
return switch (@typeInfo(T)) {
.comptime_int => isize,
.comptime_float => f64,
else => T,
};
}
pub fn ComptimeErasedStructField(field: std.builtin.Type.StructField) std.builtin.Type.StructField {
var modified = field;
modified.type = ComptimeErasedType(field.type);
modified.is_comptime = false;
modified.alignment = @alignOf(modified.type);
return modified;
}

View File

@ -0,0 +1,380 @@
const std = @import("std");
const jetquery = @import("jetquery");
const jetcommon = @import("jetcommon");
const util = @import("util.zig");
pub const ReflectOptions = struct {
header: []const u8 = "",
import_jetquery: []const u8 =
\\@import("jetquery")
,
};
pub fn Reflect(adapter_name: jetquery.adapters.Name, Schema: type) type {
return struct {
const Self = @This();
const AdaptedRepo = jetquery.Repo(adapter_name, Schema);
allocator: std.mem.Allocator,
repo: *AdaptedRepo,
options: ReflectOptions,
pub fn init(allocator: std.mem.Allocator, repo: *AdaptedRepo, options: ReflectOptions) Self {
return .{ .repo = repo, .allocator = allocator, .options = options };
}
pub fn generateSchema(self: Self) ![]const u8 {
var arena = std.heap.ArenaAllocator.init(self.allocator);
defer arena.deinit();
const allocator = arena.allocator();
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const writer = buf.writer();
try writer.print(
\\{s}
\\const jetquery = {s};
\\
, .{ self.options.header, self.options.import_jetquery });
const reflection = try self.repo.adapter.reflect(allocator, self.repo);
const map = try reflection.tableMap(allocator);
var written = std.BufSet.init(allocator);
// Write tables already defined in the schema first ...
inline for (comptime std.meta.declarations(Schema)) |decl| {
if (map.get(@field(Schema, decl.name).name)) |table| {
try writeModel(allocator, Schema, reflection, table, writer);
try written.insert(table.name);
}
}
// ... then write any remaining tables to preserve schema order if edited by user.
for (reflection.tables) |table| {
if (written.contains(table.name)) continue;
try writeModel(allocator, Schema, reflection, table, writer);
}
return try self.allocator.dupe(u8, try jetcommon.fmt.zig(
allocator,
buf.items,
"Found errors in generated schema.",
));
}
};
}
fn writeModel(
allocator: std.mem.Allocator,
comptime schema: type,
reflection: jetquery.Reflection,
table: jetquery.Reflection.TableInfo,
writer: anytype,
) !void {
const model_name = try translateTableName(allocator, schema, table.name);
try writer.print(
\\
\\pub const {s} = jetquery.Model(
\\@This(),
\\"{s}",
\\struct {{
\\
, .{ model_name, try util.zigEscape(allocator, .string, table.name) });
for (reflection.columns) |column| {
if (!std.mem.eql(u8, column.table, table.name)) continue;
try writer.print(
\\{s}: {s},
\\
,
.{ try util.zigEscape(allocator, .id, column.name), column.zigType() },
);
}
try writer.print(
\\}},
\\{s}
\\);
\\
, .{try stringifyOptions(allocator, schema, table.name)});
}
fn stringifyOptions(
allocator: std.mem.Allocator,
comptime schema: type,
table_name: []const u8,
) ![]const u8 {
inline for (comptime std.meta.declarations(schema)) |decl| {
const table = @field(schema, decl.name);
if (std.mem.eql(u8, table.name, table_name)) {
return try stringifyModelOptions(allocator, table);
}
}
return ".{}";
}
fn stringifyModelOptions(allocator: std.mem.Allocator, model: type) ![]const u8 {
var buf = std.ArrayList(u8).init(allocator);
const writer = buf.writer();
if (comptime hasDefaultOptions(model)) return ".{}";
try writer.print(
\\.{{
\\
, .{});
if (comptime !isDefaultPrimaryKey(model)) {
try writer.print(
\\.primary_key = "{s}",
\\
, .{try util.zigEscape(allocator, .string, model.primary_key)});
}
const relation_fields = std.meta.fields(@TypeOf(model.relations));
if (relation_fields.len > 0) {
try writer.print(
\\.relations = .{{
\\
, .{});
inline for (relation_fields) |field| {
const relation = @field(model.relations, field.name);
try writer.print(
\\.{s} = {s}(.{s}, {s}),
\\
, .{
try util.zigEscape(allocator, .id, field.name),
switch (relation.relation_type) {
.belongs_to => "jetquery.belongsTo",
.has_many => "jetquery.hasMany",
},
try util.zigEscape(allocator, .id, relation.relation_model_name),
try stringifyRelationOptions(allocator, relation),
});
}
try writer.print(
\\}},
\\
, .{});
}
try writer.print(
\\}},
\\
, .{});
return try buf.toOwnedSlice();
}
fn stringifyRelationOptions(allocator: std.mem.Allocator, comptime relation: type) ![]const u8 {
if (comptime relation.options.primary_key == null and
relation.options.foreign_key == null) return ".{}";
var buf = std.ArrayList(u8).init(allocator);
const writer = buf.writer();
try writer.print(".{{", .{});
if (comptime relation.options.primary_key != null) {
try writer.print(
\\.primary_key = "{s}"{s}
\\
, .{
try util.zigEscape(allocator, .string, relation.options.primary_key.?),
if (comptime relation.options.foreign_key != null) ", " else "",
});
}
if (comptime relation.options.foreign_key != null) {
try writer.print(
\\.foreign_key = "{s}"
\\
, .{
try util.zigEscape(allocator, .string, relation.options.foreign_key.?),
});
}
try writer.print("}}", .{});
return try buf.toOwnedSlice();
}
fn hasDefaultOptions(comptime model: type) bool {
comptime {
if (isDefaultPrimaryKey(model)) return false;
if (std.meta.fields(@TypeOf(model.relations)).len > 0) return false;
return true;
}
}
fn isDefaultPrimaryKey(comptime model: type) bool {
comptime {
return std.mem.eql(u8, model.primary_key, "id");
}
}
fn isDefaultForeignKey(comptime model: type) bool {
comptime {
return std.mem.eql(u8, model.foreign_key, "_id");
}
}
fn translateTableName(
allocator: std.mem.Allocator,
comptime schema: type,
name: []const u8,
) ![]const u8 {
// First try finding an existing model in the current schema.
inline for (comptime std.meta.declarations(schema)) |decl| {
if (std.mem.eql(u8, @field(schema, decl.name).name, name)) return decl.name;
}
// Then try translating the table name into a model name. This is the only time we do any
// magic with plural nouns etc. - if the user modifies the default generated value, we use
// that value going forward as it will now be in the schema.
var it = std.mem.tokenizeScalar(u8, name, '_');
var buf = std.ArrayList([]const u8).init(allocator);
while (it.next()) |token| {
var dup = try allocator.dupe(u8, token);
dup[0] = std.ascii.toUpper(dup[0]);
try buf.append(dup);
}
return try util.singularize(allocator, try std.mem.join(allocator, "", buf.items));
}
test "reflect" {
var admin_repo = try jetquery.Repo(.postgresql, void).init(
std.testing.allocator,
.{
.adapter = .{
.database = "postgres",
.username = "postgres",
.hostname = "127.0.0.1",
.password = "password",
.port = 5432,
},
},
);
defer admin_repo.deinit();
try admin_repo.dropDatabase("reflection_test", .{ .if_exists = true });
try admin_repo.createDatabase("reflection_test", .{});
const Schema = struct {
pub const Human = jetquery.Model(
@This(),
"humans",
struct { id: i32, name: []const u8 },
.{
.relations = .{
.cats = jetquery.relation.hasMany(.Cat, .{ .foreign_key = "custom_foreign_key" }),
},
},
);
pub const Cat = jetquery.Model(
@This(),
"cats",
struct { id: i32, name: []const u8, human_id: i32, paws: i32 },
.{
.relations = .{
.human = jetquery.relation.belongsTo(.Human, .{}),
},
.primary_key = "custom_primary_key",
},
);
};
var repo = try jetquery.Repo(.postgresql, Schema).init(
std.testing.allocator,
.{
.adapter = .{
.database = "reflection_test",
.username = "postgres",
.hostname = "127.0.0.1",
.password = "password",
.port = 5432,
},
},
);
defer repo.deinit();
try repo.createTable("cats", &.{
jetquery.schema.table.primaryKey("id", .{}),
jetquery.schema.table.column("name", .string, .{ .not_null = true }),
jetquery.schema.table.column("human_id", .integer, .{}),
jetquery.schema.table.timestamps(.{}),
}, .{});
try repo.createTable("humans", &.{
jetquery.schema.table.primaryKey("id", .{}),
jetquery.schema.table.column("name", .string, .{ .not_null = true }),
jetquery.schema.table.timestamps(.{}),
}, .{});
try repo.createTable("dogs", &.{
jetquery.schema.table.primaryKey("id", .{}),
jetquery.schema.table.column("name", .string, .{ .not_null = true }),
jetquery.schema.table.column("is_woofy", .boolean, .{}),
jetquery.schema.table.column("description", .text, .{}),
jetquery.schema.table.column("bark_rating", .float, .{}),
jetquery.schema.table.column("food_budget", .decimal, .{}),
jetquery.schema.table.timestamps(.{}),
}, .{});
const reflect = Reflect(.postgresql, Schema).init(std.testing.allocator, &repo, .{});
const schema = try reflect.generateSchema();
defer std.testing.allocator.free(schema);
try std.testing.expectEqualStrings(
\\const jetquery = @import("jetquery");
\\
\\pub const Human = jetquery.Model(
\\ @This(),
\\ "humans",
\\ struct {
\\ id: i32,
\\ name: []const u8,
\\ created_at: jetquery.DateTime,
\\ updated_at: jetquery.DateTime,
\\ },
\\ .{
\\ .relations = .{
\\ .cats = jetquery.hasMany(.Cat, .{ .foreign_key = "custom_foreign_key" }),
\\ },
\\ },
\\);
\\
\\pub const Cat = jetquery.Model(
\\ @This(),
\\ "cats",
\\ struct {
\\ id: i32,
\\ name: []const u8,
\\ human_id: ?i32,
\\ created_at: jetquery.DateTime,
\\ updated_at: jetquery.DateTime,
\\ },
\\ .{
\\ .primary_key = "custom_primary_key",
\\ .relations = .{
\\ .human = jetquery.belongsTo(.Human, .{}),
\\ },
\\ },
\\);
\\
\\pub const Dog = jetquery.Model(@This(), "dogs", struct {
\\ id: i32,
\\ name: []const u8,
\\ is_woofy: ?bool,
\\ description: ?[]const u8,
\\ bark_rating: ?f64,
\\ food_budget: ?[]const u8,
\\ created_at: jetquery.DateTime,
\\ updated_at: jetquery.DateTime,
\\}, .{});
\\
, schema);
}

View File

@ -0,0 +1,44 @@
const std = @import("std");
const jetquery = @import("../../jetquery.zig");
pub const TableInfo = struct {
name: []const u8,
};
pub const ColumnInfo = struct {
name: []const u8,
table: []const u8,
type: jetquery.schema.Column.Type,
null: bool,
pub fn zigType(self: ColumnInfo) []const u8 {
return switch (self.type) {
.string, .text => if (self.null) "?[]const u8" else "[]const u8",
.integer => if (self.null) "?i32" else "i32",
.float => if (self.null) "?f64" else "f64",
// TODO: Maybe create a Decimal type in jetcommon to wrap pg.Numeric (etc.).
// until there's a more standardized Zig decimal type.
.decimal => if (self.null) "?[]const u8" else "[]const u8",
.boolean => if (self.null) "?bool" else "bool",
.datetime => if (self.null) "?jetquery.DateTime" else "jetquery.DateTime",
};
}
};
allocator: std.mem.Allocator,
tables: []const TableInfo,
columns: []const ColumnInfo,
const Reflection = @This();
pub fn deinit(self: Reflection) void {
self.allocator.free(self.tables);
self.allocator.free(self.columns);
}
pub fn tableMap(self: Reflection, allocator: std.mem.Allocator) !std.StringHashMap(TableInfo) {
var map = std.StringHashMap(TableInfo).init(allocator);
for (self.tables) |table| try map.put(table.name, table);
return map;
}

View File

@ -0,0 +1,58 @@
const std = @import("std");
/// Very basic noun plural->singular conversion. This is used for translating database tables
/// into model names. If the user modifies the model name in the generated schema then any
/// subsequent schema re-generations will use the value defined by the user. For this reason we
/// do not attempt to do anything particularly clever (or deal with other languages, etc.).
pub fn singularize(allocator: std.mem.Allocator, input: []const u8) ![]const u8 {
const es_endings = [_][]const u8{ "oes", "ses", "xes", "zes", "ches", "shes" };
for (es_endings) |ending| {
if (std.mem.endsWith(u8, input, ending)) {
return try allocator.dupe(u8, input[0 .. input.len - 2]);
}
}
if (std.mem.endsWith(u8, input, "ies")) {
const base = input[0 .. input.len - 3];
return try std.mem.concat(allocator, u8, &.{ base, "y" });
}
if (std.mem.endsWith(u8, input, "s")) {
return try allocator.dupe(u8, input[0 .. input.len - 1]);
}
return try allocator.dupe(u8, input);
}
test "-s" {
const singular = try singularize(std.testing.allocator, "cats");
defer std.testing.allocator.free(singular);
try std.testing.expectEqualStrings("cat", singular);
}
test "-ies" {
const singular = try singularize(std.testing.allocator, "festivities");
defer std.testing.allocator.free(singular);
try std.testing.expectEqualStrings("festivity", singular);
}
test "-shes" {
const singular = try singularize(std.testing.allocator, "bushes");
defer std.testing.allocator.free(singular);
try std.testing.expectEqualStrings("bush", singular);
}
pub fn zigEscape(
allocator: std.mem.Allocator,
comptime context: enum { id, string },
input: []const u8,
) ![]const u8 {
var buf = std.ArrayList(u8).init(allocator);
const writer = buf.writer();
const formatter = switch (context) {
.id => std.zig.fmtId(input),
.string => std.zig.fmtEscapes(input),
};
try writer.print("{}", .{formatter});
return try buf.toOwnedSlice();
}

149
src/jetquery/relation.zig Normal file
View File

@ -0,0 +1,149 @@
const std = @import("std");
const jetquery = @import("../jetquery.zig");
pub fn RelationsEnum(Table: type) type {
return std.meta.FieldEnum(@TypeOf(Table.relations));
}
pub fn ColumnsEnum(Schema: type, Table: type, comptime name: RelationsEnum(Table)) type {
comptime {
const relation = @field(Table.relations, @tagName(name));
const source = std.enums.nameCast(std.meta.DeclEnum(Schema), relation.relation_model_name);
const Source = @field(Schema, @tagName(source));
return std.meta.FieldEnum(Source.Definition);
}
}
pub fn RelationTable(Schema: type, Table: type, comptime name: RelationsEnum(Table)) type {
const relation = @field(Table.relations, @tagName(name));
const source = std.enums.nameCast(std.meta.DeclEnum(Schema), relation.relation_model_name);
return @field(Schema, @tagName(source));
}
pub const JoinContext = enum { inner, outer, include };
pub fn concatRelations(
relations: []const type,
Schema: type,
Table: type,
comptime name: RelationsEnum(Table),
comptime relation_options: RelationOptions,
comptime join_context: JoinContext,
) *const [1 + relations.len]type {
comptime {
var types: [1 + relations.len]type = undefined;
for (relations, 0..) |relation, index| types[index] = relation;
types[relations.len] = Relation(
Schema,
Table,
name,
relation_options,
join_context,
);
const final = types;
return &final;
}
}
pub fn Relation(
Schema: type,
Table: type,
comptime name: RelationsEnum(Table),
comptime relation_options: RelationOptions,
comptime join_context: JoinContext,
) type {
comptime {
const relation = @field(Table.relations, @tagName(name));
return struct {
pub const context = join_context;
pub const Source = RelationTable(Schema, Table, name);
pub const relation_type = relation.relation_type;
pub const options = relation.options;
pub const relation_name = @tagName(name);
pub const select_columns = relation_options.select;
pub const limit = relation_options.limit;
pub const order_by = relation_options.order_by;
pub const primary_key = options.primary_key orelse "id";
pub const foreign_key: ?[]const u8 = options.foreign_key orelse switch (relation_type) {
.belongs_to => relation_name ++ "_id",
.has_many => null, // We have to infer this from the source table later.
};
};
}
}
pub const RelationOptions = struct {
select: []const jetquery.columns.Column = &.{},
limit: ?u64 = null,
order_by: ?[]const jetquery.sql.OrderClause = &.{},
};
pub fn translateRelationOptions(
Schema: type,
Model: type,
comptime name: RelationsEnum(Model),
comptime options: anytype,
) RelationOptions {
var translated: RelationOptions = .{};
const Source = RelationTable(Schema, Model, name);
translated.select = &jetquery.columns.translate(
Source,
&.{},
if (@hasField(@TypeOf(options), "select")) options.select else .{},
);
translated.limit = if (@hasField(@TypeOf(options), "limit"))
options.limit
else
null;
translated.order_by = if (@hasField(@TypeOf(options), "order_by"))
&jetquery.sql.translateOrderBy(Source, &.{}, options.order_by)
else
null;
return translated;
}
pub const RelationType = enum { belongs_to, has_many };
pub const BelongsToOptions = struct {
primary_key: ?[]const u8 = null,
foreign_key: ?[]const u8 = null,
};
pub fn belongsTo(comptime model_name: anytype, comptime belongs_to_options: BelongsToOptions) type {
return struct {
pub const relation_model_name = @tagName(model_name);
pub const relation_type: RelationType = .belongs_to;
pub const options = belongs_to_options;
};
}
pub const HasManyOptions = struct {
primary_key: ?[]const u8 = null,
foreign_key: ?[]const u8 = null,
};
pub fn hasMany(comptime model_name: anytype, comptime has_many_options: HasManyOptions) type {
return struct {
pub const relation_model_name = @tagName(model_name);
pub const relation_type: RelationType = .has_many;
pub const options = has_many_options;
};
}
fn detectLimit(options: anytype, relation_type: RelationType) ?u64 {
if (@hasField(@TypeOf(options), "limit")) {
if (relation_type != .has_many) {
@compileError(
"`limit` on `include` only supported for `has_many` relations, found: `" ++
@tagName(relation_type) ++ "`",
);
} else return @intCast(options.limit);
} else return null;
}

2
src/jetquery/schema.zig Normal file
View File

@ -0,0 +1,2 @@
pub const table = @import("schema/table.zig");
pub const Column = @import("schema/Column.zig");

View File

@ -0,0 +1,29 @@
const table = @import("table.zig");
/// A database column.
pub const Column = @This();
name: []const u8,
type: Type,
options: Options = .{},
primary_key: bool = false,
timestamps: ?table.TimestampsOptions = null,
pub const Type = enum { string, integer, float, decimal, boolean, datetime, text };
pub const Reference = [2][]const u8;
pub const Options = struct {
not_null: bool = false,
index: bool = false,
index_name: ?[]const u8 = null,
unique: bool = false,
reference: ?Reference = null,
length: ?u16 = null,
};
pub fn init(
comptime name: []const u8,
comptime column_type: Type,
comptime options: Options,
) Column {
return .{ .name = name, .type = column_type, .options = options };
}

View File

@ -0,0 +1,69 @@
const std = @import("std");
const jetquery = @import("../../jetquery.zig");
const Column = @import("Column.zig");
pub fn column(
name: []const u8,
column_type: Column.Type,
options: Column.Options,
) Column {
return .{ .name = name, .type = column_type, .options = options };
}
pub const PrimaryKeyOptions = struct {
type: Column.Type = .integer,
};
pub fn primaryKey(name: []const u8, options: PrimaryKeyOptions) Column {
return .{
.name = name,
.type = options.type,
.options = .{ .not_null = true },
.primary_key = true,
};
}
pub const TimestampsOptions = struct {
created_at: bool = true,
updated_at: bool = true,
pub fn toSql(
self: TimestampsOptions,
writer: anytype,
adapter: anytype,
) !void {
const created_at = comptime Column.init(
jetquery.default_column_names.created_at,
.datetime,
.{},
);
const updated_at = comptime Column.init(
jetquery.default_column_names.created_at,
.datetime,
.{},
);
if (self.created_at) {
try writer.print("{s}{s}{s}", .{
adapter.identifier(jetquery.default_column_names.created_at),
adapter.columnTypeSql(created_at),
adapter.notNullSql(),
});
}
if (self.created_at and self.updated_at) try writer.print(", ", .{});
if (self.updated_at) {
try writer.print("{s}{s}{s}", .{
adapter.identifier(jetquery.default_column_names.updated_at),
adapter.columnTypeSql(updated_at),
adapter.notNullSql(),
});
}
}
};
pub fn timestamps(options: TimestampsOptions) Column {
return .{ .name = undefined, .type = undefined, .options = .{}, .timestamps = options };
}

213
src/jetquery/sql.zig Normal file
View File

@ -0,0 +1,213 @@
const std = @import("std");
const jetquery = @import("../jetquery.zig");
pub const render = @import("sql/render.zig").render;
pub const renderUpdateRuntime = @import("sql/render.zig").renderUpdateRuntime;
pub const Where = @import("sql/Where.zig");
pub const FieldState = struct {
name: []const u8,
modified: bool,
};
pub const QueryContext = enum {
select,
update,
insert,
delete,
delete_all,
count,
none,
};
pub const OrderClause = struct {
// TODO: Allow ordering on relations.
column: jetquery.columns.Column,
direction: OrderDirection,
};
pub const OrderDirection = enum { ascending, descending, asc, desc };
pub const CountContext = enum { all, distinct };
pub const CountColumn = struct {
type: CountContext,
};
// Information about a column's position in a `SELECT` query, used to determine how to assign
// result values to the `ResultType`. We cannot rely on the column name returned by PostgreSQL as
// we only get the name of the column and not the table it came from, so we would otherwise not
// be able to match columns to tables in joins. This also means we can skip a few allocs in
// pg.zig by not requesting column names.
pub const ColumnInfo = struct {
index: usize,
name: []const u8,
type: type,
relation: ?type,
};
pub const FunctionContext = enum { min, max, count, avg, sum };
pub const Function = struct {
context: FunctionContext,
column_name: []const u8,
alias: []const u8,
};
fn FunctionType(comptime context: FunctionContext, comptime column_tag: anytype) type {
if (@typeInfo(@TypeOf(column_tag)) != .enum_literal) {
@compileError(std.fmt.comptimePrint(
"Expected enum literal as SQL function argument, found: `{s}`",
.{@tagName(@typeInfo(@TypeOf(column_tag)))},
));
}
return struct {
comptime __jetquery_function: Function = .{
.context = context,
.column_name = @tagName(column_tag),
.alias = @tagName(context) ++ "__" ++ @tagName(column_tag),
},
pub fn as(comptime alias: []const u8) type {
return struct {
comptime __jetquery_function: Function = .{
.context = context,
.column_name = @tagName(column_tag),
.alias = alias,
},
};
}
};
}
pub inline fn min(comptime column_tag: anytype) type {
return FunctionType(.min, column_tag);
}
pub inline fn max(comptime column_tag: anytype) type {
return FunctionType(.max, column_tag);
}
pub inline fn count(comptime column_tag: anytype) type {
return FunctionType(.count, column_tag);
}
pub inline fn avg(comptime column_tag: anytype) type {
return FunctionType(.avg, column_tag);
}
pub inline fn sum(comptime column_tag: anytype) type {
return FunctionType(.sum, column_tag);
}
pub inline fn column(T: type, comptime sql: []const u8) jetquery.columns.Column {
return jetquery.columns.Column{
.name = undefined,
.type = T,
.table = undefined,
.function = null,
.alias = null,
.sql = sql,
};
}
pub fn translateOrderBy(
Table: type,
relations: []const type,
comptime args: anytype,
) [orderBySize(@TypeOf(args))]jetquery.sql.OrderClause {
comptime {
switch (@typeInfo(@TypeOf(args))) {
.enum_literal => return .{.{
.column = Table.column(@tagName(args)),
.direction = .ascending,
}},
.@"struct" => {},
else => |tag| @compileError(
std.fmt.comptimePrint(
"Unsupported `orderBy` argument: `{s}`. Expected [enum_literal, struct]",
.{@tagName(tag)},
),
),
}
var clauses: [orderBySize(@TypeOf(args))]jetquery.sql.OrderClause = undefined;
const is_tuple = @typeInfo(@TypeOf(args)).@"struct".is_tuple;
const fields = std.meta.fields(@TypeOf(args));
var index: usize = 0;
for (fields, if (is_tuple) args else fields) |field, arg| {
if (is_tuple) {
// Short-hand (default ascending):
// orderBy(.{ .foo, .bar, .baz })
clauses[index] = .{
.column = Table.column(@tagName(arg)),
.direction = .ascending,
};
index += 1;
continue;
} else if (@hasField(Table.Definition, field.name)) {
// Explicit form:
// orderBy(.{ .foo = .ascending })
// orderBy(.{ .bar = .descending })
clauses[index] = .{
.column = Table.column(field.name),
.direction = std.enums.nameCast(
jetquery.sql.OrderDirection,
@tagName(@field(args, field.name)),
),
};
index += 1;
continue;
} else {
// Nested form, ordering by relations fields:
// orderBy(.{ .foo = .{ .bar })
// orderBy(.{ .foo = .{ .bar = .descending } })
relations: for (relations) |relation| {
if (std.mem.eql(u8, relation.relation_name, field.name)) {
const nested_clauses = translateOrderBy(
relation.Source,
&.{},
@field(args, field.name),
);
for (nested_clauses) |clause| {
clauses[index] = clause;
index += 1;
}
break :relations;
}
} else {
@compileError(
std.fmt.comptimePrint(
"Unrecognized `orderBy` field `{s}` in current table and active joins/includes.",
.{field.name},
),
);
}
}
}
return clauses;
}
}
fn orderBySize(T: type) usize {
const error_message = "Unsupported argument type for `orderBy`: `{s}`. Expected [enum_literal, struct]";
return switch (@typeInfo(T)) {
.enum_literal => 1,
.@"struct" => blk: {
var size: usize = 0;
for (std.meta.fields(T)) |field| {
size += switch (@typeInfo(field.type)) {
.enum_literal => 1,
.@"struct" => orderBySize(field.type),
else => |tag| @compileError(
std.fmt.comptimePrint(error_message, .{@tagName(tag)}),
),
};
}
break :blk size;
},
else => |tag| @compileError(
std.fmt.comptimePrint(error_message, .{@tagName(tag)}),
),
};
}

999
src/jetquery/sql/Where.zig Normal file
View File

@ -0,0 +1,999 @@
const std = @import("std");
const fields = @import("../fields.zig");
const coercion = @import("../coercion.zig");
const columns = @import("../columns.zig");
const sql = @import("../sql.zig");
const Where = @This();
fn ClauseValues(ValuesTuple: type, ErrorsTuple: type) type {
return struct {
values: ValuesTuple,
errors: ErrorsTuple,
};
}
pub const Field = struct {
name: []const u8,
Table: type,
column_type: type,
context: fields.FieldContext,
index: usize,
};
pub const Tree = struct {
Table: type,
relations: []const type,
root: Node,
values_count: usize,
values_fields: []const Field,
ValuesTuple: type,
ErrorsTuple: type,
const Counter = struct {
count: usize,
const Self = @This();
pub fn write(self: *Self, bytes: []const u8) !void {
self.count += bytes.len;
}
pub fn print(self: *Self, comptime fmt: []const u8, comptime args: anytype) !void {
self.write(std.fmt.comptimePrint(fmt, args)) catch unreachable;
}
};
pub fn render(comptime self: Tree, Adapter: type) []const u8 {
comptime {
var counter = Counter{ .count = 0 };
self.root.render(Adapter, &counter, 0, null);
var buf: [counter.count]u8 = undefined;
var stream = std.io.fixedBufferStream(&buf);
self.root.render(Adapter, stream.writer(), 0, null);
return stream.getWritten() ++ "";
}
}
pub fn values(comptime self: Tree, args: anytype) ClauseValues(self.ValuesTuple, self.ErrorsTuple) {
var vals: self.ValuesTuple = undefined;
var errors: self.ErrorsTuple = undefined;
if (@typeInfo(@TypeOf(args)) != .@"struct") @compileError(
"Expected `struct`, found `" ++ @tagName(@typeInfo(@TypeOf(args))) ++ "`",
);
assignValues(
args,
self.ValuesTuple,
&vals,
self.ErrorsTuple,
&errors,
self.values_fields,
0,
true,
);
return .{ .values = vals, .errors = errors };
}
pub fn fields(comptime self: Tree) [self.root.countValues()]Field {
return self.root.values_fields(self.Table, self.relations);
}
pub fn countValues(comptime self: Tree) usize {
return self.root.countValues();
}
};
pub fn tree(
Adapter: type,
Table: type,
relations: []const type,
T: type,
comptime field_context: fields.FieldContext,
comptime first_value_index: usize,
) Tree {
var index: usize = first_value_index;
const root = nodeTree(
Adapter,
Table,
relations,
T,
T,
"root",
undefined,
&.{},
field_context,
&index,
);
return .{
.root = root,
.Table = Table,
.relations = relations,
.values_count = root.countValues(),
.values_fields = &root.values_fields(Table, relations),
.ValuesTuple = root.ValuesTuple(),
.ErrorsTuple = root.ErrorsTuple(),
};
}
pub const Node = union(enum) {
pub const Condition = enum { NOT, AND, OR };
pub const Value = struct {
name: []const u8,
type: type,
Table: type,
field_info: std.builtin.Type.StructField,
field_context: fields.FieldContext,
index: usize,
synthetic: bool = false,
pub fn ColumnType(self: Value) type {
const T = fields.ColumnType(self.Table, fields.fieldInfo(
self.field_info,
self.Table,
self.name,
self.field_context,
));
return if (self.isArray()) []const T else T;
}
pub fn isArray(self: Value) bool {
const T = fields.ColumnType(self.Table, fields.fieldInfo(
self.field_info,
self.Table,
self.name,
self.field_context,
));
return switch (@typeInfo(self.type)) {
.pointer => |info| if (info.size == .Slice and info.child == T)
true
else
false,
else => false,
};
}
pub fn isNull(self: Value) bool {
return (self.type == @TypeOf(null));
}
};
pub const Group = struct {
name: []const u8,
children: []const Node,
};
pub const Triplet = struct {
lhs: Operand,
operator: Operator,
rhs: Operand,
pub const Operator = enum {
eql,
not_eql,
lt,
lt_eql,
gt,
gt_eql,
like,
ilike, // Not supported by all databases
};
pub const Operand = union(enum) {
value: Node.Value,
column: columns.Column,
};
};
pub const SqlString = struct {
sql: []const u8,
values: []const Node,
pub fn render(comptime self: SqlString, Adapter: type) []const u8 {
var single_quoted = false;
var double_quoted = false;
var indices: [self.values.len]usize = undefined;
var arg_index: usize = 0;
for (self.sql, 0..) |char, char_index| {
switch (char) {
'\'' => single_quoted = !single_quoted,
'"' => double_quoted = !double_quoted,
'?' => if (!double_quoted and !single_quoted) {
if (arg_index < self.values.len) {
indices[arg_index] = char_index;
}
arg_index += 1;
},
else => {},
}
}
if (arg_index != self.values.len) {
@compileError(std.fmt.comptimePrint(
"Expected {} arguments to string clause, found {}. SQL string: `{s}`",
.{ self.values.len, arg_index, self.sql },
));
}
var size: usize = 0;
var cursor: usize = 0;
for (indices, self.values) |index, node| {
const chunk = self.sql[cursor..index];
const output = chunk ++ Adapter.paramSql(node.value.index);
cursor += chunk.len + 1;
size += output.len;
}
var buf: [size]u8 = undefined;
var input_cursor: usize = 0;
var output_cursor: usize = 0;
for (indices, self.values) |index, node| {
const chunk = self.sql[input_cursor..index];
const output = chunk ++ Adapter.paramSql(node.value.index);
@memcpy(buf[output_cursor .. output_cursor + output.len], output);
input_cursor += chunk.len + 1;
output_cursor += output.len;
}
const final = buf;
return &final;
}
};
condition: Condition,
value: Value,
group: Group,
triplet: Triplet,
sql_string: SqlString,
pub fn render(
self: Node,
Adapter: type,
comptime writer: anytype,
comptime depth: usize,
comptime prev: ?Node,
) void {
switch (self) {
.condition => |capture| {
const operator = switch (capture) {
.NOT => if (prev == null) "NOT" else "AND NOT",
else => |tag| @tagName(tag),
};
writer.print(" {s} ", .{operator}) catch unreachable;
},
.value => |value| {
const is_sequence = if (prev) |capture| capture == .value else false;
const prefix = if (is_sequence) " AND " else "";
if (value.type == @TypeOf(null)) {
writer.print("{s}{s}.{s} IS NULL", .{
prefix,
Adapter.identifier(value.Table.name),
Adapter.identifier(value.name),
}) catch unreachable;
} else {
writer.print("{s}{s}.{s} = {s}", .{
prefix,
Adapter.identifier(value.Table.name),
Adapter.identifier(value.name),
if (value.isArray())
// XXX: This is PostgreSQL-specific - one day we'll need to figure
// out how to generate SQL for unknown (at comptime) array length.
// MySQL has `ANY` but it expects a subquery so maybe we'll need a
// temporary table or something equally horrible. SQLite doesn't have
// `ANY` at all so we may end up having to generate some parts of the
// SQL at runtime. :(
Adapter.anyParamSql(value.index)
else
Adapter.paramSql(value.index),
}) catch unreachable;
}
},
.group => |group| {
if (group.children.len > 1) writer.print("(", .{}) catch unreachable;
var prev_child: ?Node = null;
for (group.children) |child| {
if (prev_child) |capture| {
const is_and = switch (child) {
.group, .triplet => switch (capture) {
.group, .value, .triplet => true,
else => false,
},
else => false,
};
if (is_and) {
writer.print(" AND ", .{}) catch unreachable;
}
}
child.render(Adapter, writer, depth + 1, prev_child);
prev_child = child;
}
if (group.children.len > 1) writer.print(")", .{}) catch unreachable;
},
.triplet => |triplet| {
switch (triplet.lhs) {
.value => |value| {
writer.print("{s}", .{Adapter.paramSql(value.index)}) catch unreachable;
},
.column => |column| {
writer.print("{s}", .{Adapter.columnSql(column)}) catch unreachable;
},
}
const operator = switch (triplet.operator) {
.eql => "=",
.not_eql => "<>",
.lt => "<",
.lt_eql => "<=",
.gt => ">",
.gt_eql => ">=",
.like => "LIKE",
.ilike => "ILIKE", // Not supported by all databases
};
writer.print(" {s} ", .{operator}) catch unreachable;
switch (triplet.rhs) {
.value => |value| {
writer.print("{s}", .{Adapter.paramSql(value.index)}) catch unreachable;
},
.column => |column| {
writer.print("{s}", .{Adapter.columnSql(column)}) catch unreachable;
},
}
},
.sql_string => |sql_string| {
_ = writer.write(sql_string.render(Adapter)) catch unreachable;
},
}
}
fn ValuesTuple(comptime self: Node) type {
const len = self.countValues();
var types: [len]type = undefined;
var index: usize = 0;
appendValueType(self, len, &types, &index);
return std.meta.Tuple(&types);
}
fn appendValueType(
comptime node: Node,
comptime len: usize,
types: *[len]type,
index: *usize,
) void {
switch (node) {
.condition => {},
.value => |value| {
if (!value.isNull()) {
types[index.*] = value.ColumnType();
index.* += 1;
}
},
.group => |group| {
for (group.children) |child| appendValueType(child, len, types, index);
},
.triplet => |triplet| {
switch (triplet.lhs) {
.value => |value| {
if (!value.isNull()) {
types[index.*] = value.type;
index.* += 1;
}
},
.column => {},
}
switch (triplet.rhs) {
.value => |value| {
if (!value.isNull()) {
types[index.*] = value.type;
index.* += 1;
}
},
.column => {},
}
},
.sql_string => |sql_string| {
for (sql_string.values) |value_node| {
if (@typeInfo(value_node.value.type) == .@"struct") {
@compileError(std.fmt.comptimePrint(
"Unsupported type in SQL string arguments: `struct`. SQL string: `{s}`",
.{sql_string.sql},
));
}
appendValueType(value_node, len, types, index);
}
},
}
}
fn ErrorsTuple(comptime self: Node) type {
var types: [self.countValues()]type = undefined;
for (0..self.countValues()) |index| {
types[index] = ?anyerror;
}
return std.meta.Tuple(&types);
}
fn values_fields(comptime self: Node, Table: type, relations: []const type) [self.countValues()]Field {
const len = self.countValues();
var fields_array: [len]Field = undefined;
var tuple_index: usize = 0;
appendField(self, Table, relations, len, &fields_array, &tuple_index);
return fields_array;
}
fn appendValueField(
T: type,
comptime value: Node.Value,
comptime len: usize,
fields_array: *[len]Field,
tuple_index: *usize,
) void {
if (!value.isNull()) {
fields_array[tuple_index.*] = .{
.Table = value.Table,
.name = value.name,
.context = value.field_context,
.column_type = T,
.index = tuple_index.*,
};
tuple_index.* += 1;
}
}
fn appendField(
node: Node,
Table: type,
relations: []const type,
comptime len: usize,
fields_array: *[len]Field,
tuple_index: *usize,
) void {
switch (node) {
.condition => {},
.value => |value| {
appendValueField(value.ColumnType(), value, len, fields_array, tuple_index);
},
.group => |group| {
for (group.children) |child| {
appendField(child, Table, relations, len, fields_array, tuple_index);
}
},
.triplet => |triplet| {
switch (triplet.lhs) {
.value => |value| {
appendValueField(value.type, value, len, fields_array, tuple_index);
},
.column => {},
}
switch (triplet.rhs) {
.value => |value| {
appendValueField(value.type, value, len, fields_array, tuple_index);
},
.column => {},
}
},
.sql_string => |sql_string| {
for (sql_string.values) |value_node| {
const value = value_node.value;
appendValueField(value.type, value, len, fields_array, tuple_index);
}
},
}
}
fn countValues(comptime self: Node) usize {
var count: usize = 0;
countNodeValues(self, &count);
return count;
}
fn countNodeValues(comptime node: Node, count: *usize) void {
switch (node) {
.condition => {},
.value => |value| {
if (!value.isNull()) count.* += 1;
},
.group => |group| {
for (group.children) |child| countNodeValues(child, count);
},
.triplet => |triplet| {
switch (triplet.lhs) {
.value => |value| {
if (!value.isNull()) count.* += 1;
},
.column => {},
}
switch (triplet.rhs) {
.value => |value| {
if (!value.isNull()) count.* += 1;
},
.column => {},
}
},
.sql_string => |sql_string| {
for (sql_string.values) |value_node| {
countNodeValues(value_node, count);
}
},
}
}
};
fn nodeTree(
Adapter: type,
Table: type,
relations: []const type,
OG: type,
T: type,
comptime name: []const u8,
field_info: std.builtin.Type.StructField,
comptime path: [][]const u8,
comptime field_context: fields.FieldContext,
comptime value_index: *usize,
) Node {
comptime {
if (coercion.canCoerceDelegate(T)) {
const value = Node.Value{
.field_context = field_context,
.Table = findRelation(Table, relations, path),
.name = name,
.type = T,
.field_info = field_info,
.index = value_index.*,
};
value_index.* += 1;
return .{ .value = value };
}
return switch (@typeInfo(T)) {
.@"struct" => |info| if (isTriplet(T)) blk: {
break :blk .{ .triplet = makeTriplet(
Adapter,
Table,
relations,
T,
field_context,
field_info,
name,
path,
value_index,
) };
} else if (isSqlString(T)) blk: {
const t: T = undefined;
const value_fields = std.meta.fields(@TypeOf(t[1]));
var nodes: [value_fields.len]Node = undefined;
for (value_fields, 0..) |value_field, index| {
nodes[index] = .{
.value = .{
// Name is used for type coercion, for an SQL string arg we don't
// have a target column so name is not useful.
.name = "_",
.type = fields.ComptimeErasedType(value_field.type),
.Table = Table,
.field_info = fields.ComptimeErasedStructField(value_field),
.field_context = field_context,
.index = value_index.*,
},
};
value_index.* += 1;
}
const final = nodes;
break :blk .{ .sql_string = .{ .sql = t[0], .values = &final } };
} else blk: {
const nodes = childNodes(
Adapter,
Table,
relations,
OG,
field_info,
info,
path,
field_context,
value_index,
);
break :blk .{ .group = .{ .name = name, .children = &nodes } };
},
.enum_literal => blk: {
if (path.len == 0) unreachable;
var t: type = OG;
for (path[0 .. path.len - 1]) |c| {
t = std.meta.FieldType(t, std.enums.nameCast(std.meta.FieldEnum(t), c));
}
const value: t = undefined;
const condition = @field(value, path[path.len - 1]);
break :blk .{ .condition = condition };
},
.null => .{
.value = .{
.field_context = field_context,
.Table = findRelation(Table, relations, path),
.name = name,
.type = T,
.field_info = field_info,
// We write `IS NULL` directly into the SQL without a bind param
.index = undefined,
},
},
else => blk: {
const value = Node.Value{
.field_context = field_context,
.Table = findRelation(Table, relations, path),
.name = name,
.type = T,
.field_info = field_info,
.index = value_index.*,
};
value_index.* += 1;
break :blk .{ .value = value };
},
};
}
}
fn childNodes(
Adapter: type,
Table: type,
relations: []const type,
OG: type,
comptime field_info: std.builtin.Type.StructField,
comptime struct_info: std.builtin.Type.Struct,
comptime path: [][]const u8,
comptime field_context: fields.FieldContext,
comptime value_index: *usize,
) [struct_info.fields.len]Node {
var nodes: [struct_info.fields.len]Node = undefined;
for (struct_info.fields, 0..) |field, index| {
var appended_path: [path.len + 1][]const u8 = undefined;
for (0..path.len) |idx| appended_path[idx] = path[idx];
appended_path[appended_path.len - 1] = field.name;
nodes[index] = nodeTree(
Adapter,
Table,
relations,
OG,
field.type,
field.name,
field_info,
&appended_path,
field_context,
value_index,
);
}
return nodes;
}
fn findRelation(Table: type, relations: []const type, comptime path: [][]const u8) type {
comptime {
if (path.len <= 1) return Table;
for (relations) |relation| {
if (std.mem.eql(u8, relation.relation_name, path[path.len - 2])) return relation.Source;
}
return Table;
}
}
fn assignValues(
arg: anytype,
ValuesTuple: type,
values_tuple: *ValuesTuple,
ErrorsTuple: type,
errors_tuple: *ErrorsTuple,
values_fields: []const Field,
comptime tuple_index: usize,
comptime coerce: bool,
) void {
if (comptime coercion.canCoerceDelegate(@TypeOf(arg))) {
assignValue(
arg,
ValuesTuple,
values_tuple,
ErrorsTuple,
errors_tuple,
values_fields,
tuple_index,
coerce,
);
return;
}
comptime var idx: usize = tuple_index;
switch (@typeInfo(@TypeOf(arg))) {
.@"struct" => |info| {
// XXX: Note that we will always land here first because the first arg to `where` is
// is always a struct, so we can safely start here for incrementing our index
// counter.
if (comptime isSqlString(@TypeOf(arg))) {
assignValues(
arg[1],
ValuesTuple,
values_tuple,
ErrorsTuple,
errors_tuple,
values_fields,
idx,
false,
);
} else {
inline for (info.fields) |field| {
assignValues(
@field(arg, field.name),
ValuesTuple,
values_tuple,
ErrorsTuple,
errors_tuple,
values_fields,
idx,
coerce,
);
comptime detectValues(field.type, &idx);
}
}
},
.type => {},
.enum_literal => {},
.null => {},
else => {
assignValue(
arg,
ValuesTuple,
values_tuple,
ErrorsTuple,
errors_tuple,
values_fields,
tuple_index,
coerce,
);
},
}
}
fn detectValues(T: type, index: *usize) void {
comptime {
if (coercion.canCoerceDelegate(T)) {
index.* += 1;
return;
}
switch (@typeInfo(T)) {
.@"struct" => |info| {
for (info.fields) |field| detectValues(field.type, index);
},
.type, .enum_literal, .null => {},
else => index.* += 1,
}
}
}
fn assignValue(
arg: anytype,
ValuesTuple: type,
values_tuple: *ValuesTuple,
ErrorsTuple: type,
errors_tuple: *ErrorsTuple,
values_fields: []const Field,
comptime tuple_index: usize,
comptime coerce: bool,
) void {
inline for (
std.meta.fields(ValuesTuple),
values_fields,
0..,
) |field, value_field, index| {
const tuple_field_name = std.fmt.comptimePrint("{d}", .{tuple_index});
if (comptime tuple_index == index) {
const field_info = comptime fields.fieldInfo(
field,
value_field.Table,
value_field.name,
value_field.context,
);
if (comptime coerce) {
const coerced: coercion.CoercedValue(
value_field.column_type,
@TypeOf(arg),
) = coercion.coerce(
value_field.Table,
field_info,
arg,
);
@field(values_tuple, tuple_field_name) = coerced.value;
@field(errors_tuple, tuple_field_name) = coerced.err;
} else {
// When an SQL string is used we don't have a target type to coerce to, so assign
// the value directly - user is responsible for coercing to appropriate types.
@field(values_tuple, tuple_field_name) = arg;
@field(errors_tuple, tuple_field_name) = null;
}
}
}
}
// A triplet with an operator, e.g.:
// ```zig
// .{ .foo, .eql, .bar }
// .{ sql.max(.foo), .lt, .bar }
fn isTriplet(T: type) bool {
const struct_fields = std.meta.fields(T);
if (struct_fields.len != 3) return false;
if (@typeInfo(struct_fields[1].type) != .enum_literal) return false;
const t: T = undefined;
return @hasField(Node.Triplet.Operator, @tagName(t[1]));
}
// A pair of comptime SQL string and a tuple of values, e.g.:
// ```zig
// .{ "foo = ?", .{1} }
// ```
fn isSqlString(T: type) bool {
const struct_fields = std.meta.fields(T);
if (struct_fields.len != 2) return false;
if (@typeInfo(struct_fields[1].type) != .@"struct") return false;
const is_string = switch (@typeInfo(struct_fields[0].type)) {
.pointer => |info| switch (@typeInfo(info.child)) {
.array => |array_info| info.is_volatile == false and
array_info.child == u8 and
(info.size == .Slice or info.size == .One),
.int => |int_info| info.is_volatile == false and
int_info.signedness == .unsigned and
int_info.bits == 8,
else => false,
},
else => false,
};
if (!is_string) return false;
return if (!struct_fields[0].is_comptime) @compileError(
"Custom string clauses must be comptime-known.",
) else true;
}
fn makeTriplet(
Adapter: type,
Table: type,
relations: []const type,
T: type,
comptime field_context: fields.FieldContext,
comptime field_info: std.builtin.Type.StructField,
comptime name: []const u8,
comptime path: [][]const u8,
comptime value_index: *usize,
) Node.Triplet {
const arg: T = undefined;
return .{
.lhs = makeOperand(
Adapter,
Table,
T,
if (@TypeOf(arg[2]) == type) arg[2] else @TypeOf(arg[2]),
0,
2,
relations,
field_context,
name,
path,
field_info,
value_index,
),
.operator = std.enums.nameCast(Node.Triplet.Operator, arg[1]),
.rhs = makeOperand(
Adapter,
Table,
T,
if (@TypeOf(arg[0]) == type) arg[0] else @TypeOf(arg[0]),
2,
0,
relations,
field_context,
name,
path,
field_info,
value_index,
),
};
}
fn makeOperand(
Adapter: type,
Table: type,
T: type,
Other: type,
comptime arg_index: usize,
comptime other_arg_index: usize,
relations: []const type,
comptime field_context: fields.FieldContext,
comptime name: []const u8,
comptime path: [][]const u8,
comptime field_info: std.builtin.Type.StructField,
comptime value_index: *usize,
) Node.Triplet.Operand {
const arg: T = undefined;
return switch (@typeInfo(@TypeOf(arg[arg_index]))) {
.enum_literal => .{
.column = columns.translate(Table, relations, .{arg[arg_index]})[0],
},
.type => functionColumn(arg[arg_index], Table, relations),
else => blk: {
const A = switch (@typeInfo(Other)) {
.type => Adapter.Aggregate(functionColumn(Other).function.?),
.enum_literal => enum_blk: {
const column = columns.translate(Table, relations, .{arg[other_arg_index]})[0];
break :enum_blk column.type;
},
// We're comparing two values (not a column and a value),
// let Zig reconsile the types:
else => fields.ComptimeErasedType(@TypeOf(arg[arg_index])),
};
const value: Node.Value = .{
.field_context = field_context,
.Table = findRelation(Table, relations, path),
.name = name,
.type = A,
.field_info = field_info,
.index = value_index.*,
};
value_index.* += 1;
break :blk .{ .value = value };
},
};
}
fn functionColumn(T: type, Table: type, relations: []const type) Node.Triplet.Operand {
return if (@hasField(T, "__jetquery_function"))
.{ .column = columns.translate(
Table,
relations,
.{T},
)[0] }
else
@compileError("Unexpected type in clause: `" ++ @typeName(T) ++ "`");
}
fn debugNode(comptime node: Node, comptime depth: usize) void {
const indent = " " ** depth;
switch (node) {
.condition => |value| {
@compileLog(std.fmt.comptimePrint("{s}{s}", .{ indent, @tagName(value) }));
},
.value => |value| {
@compileLog(std.fmt.comptimePrint("{s}{s}", .{ indent, value.name }));
},
.group => |group| {
for (group.children) |child| {
debugNode(child, depth + 1);
}
},
.triplet => |triplet| {
@compileLog(std.fmt.comptimePrint("{s}{s}{s}{s}", .{
indent,
@tagName(triplet.lhs),
@tagName(triplet.operator),
@tagName(triplet.rhs),
}));
},
}
}
fn debug(value: anytype) void {
@compileLog(std.fmt.comptimePrint("{any}", .{value}));
}

660
src/jetquery/sql/render.zig Normal file
View File

@ -0,0 +1,660 @@
const std = @import("std");
const jetquery = @import("../../jetquery.zig");
pub fn render(
Adapter: type,
query_context: jetquery.sql.QueryContext,
Table: type,
relations: []const type,
comptime field_infos: []const jetquery.fields.FieldInfo,
comptime columns: []const jetquery.columns.Column,
comptime order_clauses: []const jetquery.sql.OrderClause,
comptime distinct: ?[]const jetquery.columns.Column,
comptime where_clauses: []const jetquery.sql.Where.Tree,
comptime group_by: ?[]const jetquery.columns.Column,
comptime having_clauses: []const jetquery.sql.Where.Tree,
) []const u8 {
return switch (query_context) {
.select => renderSelect(
Adapter,
Table,
relations,
columns,
field_infos,
order_clauses,
where_clauses,
group_by,
having_clauses,
),
.update => renderUpdate(
Adapter,
Table,
where_clauses,
field_infos,
),
.insert => renderInsert(
Adapter,
Table,
field_infos,
),
.delete, .delete_all => renderDelete(
Adapter,
Table,
field_infos,
where_clauses,
query_context,
),
.count => renderCount(
Adapter,
Table,
relations,
field_infos,
where_clauses,
distinct,
),
.none => "",
};
}
fn renderSelect(
Adapter: type,
Table: type,
relations: []const type,
comptime columns: []const jetquery.columns.Column,
comptime field_infos: []const jetquery.fields.FieldInfo,
comptime order_clauses: []const jetquery.sql.OrderClause,
comptime where_clauses: []const jetquery.sql.Where.Tree,
comptime group_by: ?[]const jetquery.columns.Column,
comptime having_clauses: []const jetquery.sql.Where.Tree,
) []const u8 {
comptime {
const select_columns = renderSelectColumns(Adapter, relations, columns);
const from = std.fmt.comptimePrint(" FROM {s}", .{Adapter.identifier(Table.name)});
const joins = renderJoins(Adapter, Table, relations);
return std.fmt.comptimePrint(
"SELECT{s}{s}{s}{s}{s}{s}{s}",
.{
select_columns,
from,
joins,
renderWhere(Adapter, where_clauses),
renderGroupBy(Adapter, group_by, having_clauses),
renderOrder(Adapter, order_clauses),
renderLimit(Adapter, field_infos),
},
);
}
}
fn renderUpdate(
Adapter: type,
Table: type,
where_clauses: []const jetquery.sql.Where.Tree,
comptime field_infos: []const jetquery.fields.FieldInfo,
) []const u8 {
var buf: [paramsBufSize(Adapter, field_infos, .update, .assign)]u8 = undefined;
return std.fmt.comptimePrint(
"UPDATE {s} SET {s}{s}",
.{
Adapter.identifier(Table.name),
renderParams(&buf, Adapter, field_infos, .update, .assign),
renderWhere(Adapter, where_clauses),
},
);
}
pub fn renderUpdateRuntime(
allocator: std.mem.Allocator,
comptime Adapter: type,
comptime Table: type,
comptime where_clauses: []const jetquery.sql.Where.Tree,
comptime Args: type,
field_states: []const jetquery.sql.FieldState,
first_value_index: usize,
) ![]const u8 {
var value_index = first_value_index;
var params_buf = std.ArrayList(u8).init(allocator);
defer params_buf.deinit();
const params_writer = params_buf.writer();
inline for (std.meta.fields(Args), 0..) |field, index| {
if (field_states[index].modified) {
var param_buf: [8]u8 = undefined;
try params_writer.print(
"{s}{s} = {s}",
.{
if (value_index > first_value_index) ", " else "",
Adapter.identifier(field.name),
try Adapter.paramSqlBuf(&param_buf, value_index),
},
);
value_index += 1;
}
}
return try std.fmt.allocPrint(
allocator,
"UPDATE {s} SET {s}{s}",
.{
Adapter.identifier(Table.name),
params_buf.items,
comptime renderWhere(Adapter, where_clauses),
},
);
}
fn renderInsert(
Adapter: type,
Table: type,
comptime field_infos: []const jetquery.fields.FieldInfo,
) []const u8 {
var params_buf: [paramsBufSize(Adapter, field_infos, .insert, .column)]u8 = undefined;
var values_buf: [paramsBufSize(Adapter, field_infos, .insert, .value)]u8 = undefined;
return std.fmt.comptimePrint(
"INSERT INTO {s} ({s}) VALUES ({s})",
.{
Adapter.identifier(Table.name),
renderParams(&params_buf, Adapter, field_infos, .insert, .column),
renderParams(&values_buf, Adapter, field_infos, .insert, .value),
},
);
}
fn renderDelete(
Adapter: type,
Table: type,
comptime field_infos: []const jetquery.fields.FieldInfo,
comptime where_clauses: []const jetquery.sql.Where.Tree,
comptime query_context: jetquery.sql.QueryContext,
) []const u8 {
const statement = std.fmt.comptimePrint("DELETE FROM {s}", .{Adapter.identifier(Table.name)});
return switch (query_context) {
.delete, .delete_all => std.fmt.comptimePrint("{s}{s}{s}", .{
statement,
renderWhere(Adapter, where_clauses),
renderLimit(Adapter, field_infos),
}),
else => |tag| @compileError(
"Inconsistent query for DELETE: `" ++ @tagName(tag) ++ "` (this is a bug)",
),
};
}
fn renderCount(
Adapter: type,
Table: type,
comptime relations: []const type,
comptime field_infos: []const jetquery.fields.FieldInfo,
comptime where_clauses: []const jetquery.sql.Where.Tree,
comptime distinct: ?[]const jetquery.columns.Column,
) []const u8 {
comptime {
const count_column = " " ++ Adapter.countSql(distinct);
const from = std.fmt.comptimePrint(" FROM {s}", .{Adapter.identifier(Table.name)});
const joins = renderJoins(Adapter, Table, relations);
return std.fmt.comptimePrint(
"SELECT{s}{s}{s}{s}{s}",
.{
count_column,
from,
joins,
renderWhere(Adapter, where_clauses),
renderLimit(Adapter, field_infos),
},
);
}
}
fn renderLimit(
Adapter: type,
comptime field_infos: []const jetquery.fields.FieldInfo,
) []const u8 {
if (!hasParam(field_infos, .limit)) return "";
const offset = renderOffset(Adapter, field_infos);
return std.fmt.comptimePrint(
" LIMIT {s}{s}",
.{ Adapter.paramSql(lastParamIndex(field_infos, .limit)), offset },
);
}
fn renderOffset(
Adapter: type,
comptime field_infos: []const jetquery.fields.FieldInfo,
) []const u8 {
if (!hasParam(field_infos, .offset)) return "";
return std.fmt.comptimePrint(
" OFFSET {s}",
.{Adapter.paramSql(lastParamIndex(field_infos, .offset))},
);
}
fn renderOrder(
Adapter: type,
comptime order_clauses: []const jetquery.sql.OrderClause,
) []const u8 {
if (order_clauses.len == 0) return "";
var size: usize = 0;
for (order_clauses, 0..) |order_clause, index| {
const separator = if (index + 1 < order_clauses.len) ", " else "";
size += (Adapter.orderSql(order_clause) ++ separator).len;
}
var order_buf: [size]u8 = undefined;
var cursor: usize = 0;
for (order_clauses, 0..) |order_clause, index| {
const separator = if (index + 1 < order_clauses.len) ", " else "";
const order_sql = Adapter.orderSql(order_clause) ++ separator;
@memcpy(order_buf[cursor .. cursor + order_sql.len], order_sql);
cursor += order_sql.len;
}
return std.fmt.comptimePrint(
" ORDER BY {s}",
.{order_buf},
);
}
fn renderGroupBy(
Adapter: type,
comptime maybe_group_by: ?[]const jetquery.columns.Column,
comptime having_clauses: []const jetquery.sql.Where.Tree,
) []const u8 {
const group_by = maybe_group_by orelse return "";
var size: usize = 0;
for (group_by, 0..) |column, index| {
const separator = if (index + 1 < group_by.len) ", " else "";
const column_sql = Adapter.columnSql(column) ++ separator;
size += column_sql.len;
}
const and_operator = " AND ";
const having = " HAVING ";
if (having_clauses.len > 0) size += having.len;
for (having_clauses, 0..) |clause, index| {
if (index > 0) size += and_operator.len;
size += clause.render(Adapter).len;
}
var buf: [size]u8 = undefined;
var cursor: usize = 0;
for (group_by, 0..) |column, index| {
const separator = if (index + 1 < group_by.len) ", " else "";
const column_sql = Adapter.columnSql(column) ++ separator;
@memcpy(buf[cursor .. cursor + column_sql.len], column_sql);
cursor += column_sql.len;
}
if (having_clauses.len > 0) {
@memcpy(buf[cursor .. cursor + having.len], having);
cursor += having.len;
}
for (having_clauses, 0..) |clause, index| {
if (index > 0) size += and_operator.len;
const operator = if (index > 0) and_operator else "";
const sql = operator ++ clause.render(Adapter);
@memcpy(buf[cursor .. cursor + sql.len], sql);
cursor += sql.len;
}
return std.fmt.comptimePrint(" GROUP BY {s}", .{buf});
}
fn renderWhere(
Adapter: type,
comptime where_clauses: []const jetquery.sql.Where.Tree,
) []const u8 {
if (where_clauses.len == 0) return " WHERE " ++ Adapter.emptyWhereSql();
const and_operator = " AND ";
var size: usize = 0;
for (where_clauses, 0..) |clause, index| {
if (index > 0) size += and_operator.len;
size += clause.render(Adapter).len;
}
var buf: [size]u8 = undefined;
var cursor: usize = 0;
for (where_clauses, 0..) |clause, index| {
const operator = if (index > 0) and_operator else "";
const sql = operator ++ clause.render(Adapter);
@memcpy(buf[cursor .. cursor + sql.len], sql);
cursor += sql.len;
}
return std.fmt.comptimePrint(" WHERE {s}", .{&buf});
}
fn renderJoins(Adapter: type, Table: type, relations: []const type) []const u8 {
comptime {
if (relations.len == 0) return "";
var buf_len: usize = 0;
for (relations) |Relation| {
buf_len += switch (Relation.context) {
.inner => renderInnerJoin(Adapter, Table, Relation).len,
.outer => renderOuterJoin(Adapter, Table, Relation).len,
.include => switch (Relation.relation_type) {
.belongs_to => renderInnerJoin(Adapter, Table, Relation).len,
.has_many => "".len,
},
};
}
var buf: [buf_len]u8 = undefined;
var cursor: usize = 0;
for (relations) |Relation| {
const sql = switch (Relation.context) {
.inner => renderInnerJoin(Adapter, Table, Relation),
.outer => renderOuterJoin(Adapter, Table, Relation),
.include => switch (Relation.relation_type) {
.belongs_to => renderInnerJoin(Adapter, Table, Relation),
.has_many => "", // We issue separate queries for has_many
},
};
@memcpy(buf[cursor .. cursor + sql.len], sql);
cursor += sql.len;
}
return &buf;
}
}
fn renderInnerJoin(Adapter: type, Table: type, Relation: type) []const u8 {
const PrimaryKey = std.meta.FieldEnum(Relation.Source.Definition);
const ForeignKey = std.meta.FieldEnum(Table.Definition);
const primary_key: PrimaryKey = std.enums.nameCast(
PrimaryKey,
switch (Relation.relation_type) {
.belongs_to => Relation.primary_key,
.has_many => Table.defaultForeignKey(),
},
);
const foreign_key: ForeignKey = std.enums.nameCast(
ForeignKey,
Relation.foreign_key orelse switch (Relation.relation_type) {
.belongs_to => Table.primary_key,
.has_many => Relation.Source.primary_key,
},
);
return Adapter.innerJoinSql(
Table,
Relation.Source,
Relation.relation_name,
.{ .primary_key = @tagName(primary_key), .foreign_key = @tagName(foreign_key) },
);
}
fn renderOuterJoin(Adapter: type, Table: type, Relation: type) []const u8 {
const PrimaryKey = std.meta.FieldEnum(Relation.Source.Definition);
const ForeignKey = std.meta.FieldEnum(Table.Definition);
const primary_key: PrimaryKey = std.enums.nameCast(
PrimaryKey,
switch (Relation.relation_type) {
.belongs_to => Relation.primary_key,
.has_many => Table.defaultForeignKey(),
},
);
const foreign_key: ForeignKey = std.enums.nameCast(
ForeignKey,
Relation.foreign_key orelse switch (Relation.relation_type) {
.belongs_to => Table.primary_key,
.has_many => Relation.Source.primary_key,
},
);
return Adapter.outerJoinSql(
Table,
Relation.Source,
Relation.relation_name,
.{ .primary_key = @tagName(primary_key), .foreign_key = @tagName(foreign_key) },
);
}
fn renderSelectColumns(
Adapter: type,
relations: []const type,
comptime columns: []const jetquery.columns.Column,
) []const u8 {
comptime {
var total_columns: usize = columns.len;
for (relations) |Relation| {
// has_many relations issue a separate query so we don't select columns here.
// Only belongs_to uses an inner join.
if (Relation.relation_type != .belongs_to) continue;
total_columns += Relation.select_columns.len;
}
var columns_buf_len: usize = 0;
for (columns, 0..) |column, index| {
columns_buf_len += renderSelectColumn(
Adapter,
column,
index,
total_columns,
).len;
}
var start = columns.len;
for (relations) |Relation| {
// has_many relations issue a separate query so we don't select columns here.
// Only belongs_to uses an inner join.
if (Relation.relation_type != .belongs_to) continue;
for (Relation.select_columns, start..) |column, index| {
columns_buf_len += renderSelectColumn(
Adapter,
column,
index,
total_columns,
).len;
}
start += Relation.select_columns.len;
}
var columns_buf: [columns_buf_len]u8 = undefined;
var cursor: usize = 0;
for (columns, 0..) |column, index| {
const column_tag = renderSelectColumn(
Adapter,
column,
index,
total_columns,
);
@memcpy(columns_buf[cursor .. cursor + column_tag.len], column_tag);
cursor += column_tag.len;
}
start = columns.len;
for (relations) |Relation| {
// has_many relations issue a separate query so we don't select columns here.
// Only belongs_to uses an inner join.
if (Relation.relation_type != .belongs_to) continue;
for (Relation.select_columns, start..) |column, index| {
const column_tag = renderSelectColumn(
Adapter,
column,
index,
total_columns,
);
@memcpy(columns_buf[cursor .. cursor + column_tag.len], column_tag);
cursor += column_tag.len;
}
start += Relation.select_columns.len;
}
return &columns_buf;
}
}
fn renderSelectColumn(
Adapter: type,
comptime column: jetquery.columns.Column,
comptime index: usize,
comptime total: usize,
) []const u8 {
comptime {
return std.fmt.comptimePrint(
" {s}{s}",
.{ Adapter.columnSql(column), if (index + 1 < total) "," else "" },
);
}
}
// TODO: Find a nicer way of counting so we don't have to keep this sync'ed with `renderParams`
fn paramsBufSize(
Adapter: type,
comptime field_infos: []const jetquery.fields.FieldInfo,
comptime context: jetquery.fields.FieldContext,
comptime format: enum { column, value, assign },
) usize {
var buf_len: usize = 0;
const separator = switch (context) {
.where => " AND ",
.update, .insert => ", ",
else => @compileError("Unsupported param type: `" ++ @tagName(context) ++ "`"),
};
const last_param_index = lastParamIndex(field_infos, context);
const template = switch (format) {
.column, .value => "{s}{s}",
.assign => "{s} = {s}{s}",
};
for (field_infos, 0..) |field, index| {
if (!fieldContext(field_infos, index, context)) continue;
const args = switch (format) {
.column => .{
switch (context) {
.insert => Adapter.identifier(field.name),
else => Adapter.columnSql(field),
},
if (index < last_param_index) separator else "",
},
.value => .{
Adapter.paramSql(index),
if (index < last_param_index) separator else "",
},
.assign => switch (context) {
.update => .{
Adapter.identifier(field.name),
Adapter.paramSql(index),
if (index < last_param_index) separator else "",
},
else => .{
Adapter.columnSql(field),
Adapter.paramSql(index),
if (index < last_param_index) separator else "",
},
},
};
buf_len += std.fmt.count(template, args);
}
return buf_len;
}
fn renderParams(
buf: []u8,
Adapter: type,
comptime field_infos: []const jetquery.fields.FieldInfo,
comptime context: jetquery.fields.FieldContext,
comptime format: enum { column, value, assign },
) []const u8 {
if (!hasParam(field_infos, context)) @compileError("Failed compiling UPDATE query with empty params.");
const separator = switch (context) {
.where => " AND ",
.update, .insert => ", ",
else => @compileError("Unsupported param type: `" ++ @tagName(context) ++ "`"),
};
const last_param_index = lastParamIndex(field_infos, context);
var cursor: usize = 0;
const template = switch (format) {
.column, .value => "{s}{s}",
.assign => "{s} = {s}{s}",
};
for (field_infos, 0..) |field, index| {
if (!fieldContext(field_infos, index, context)) continue;
const args = switch (format) {
.column => .{
switch (context) {
.insert => Adapter.identifier(field.name),
else => Adapter.columnSql(field),
},
if (index < last_param_index) separator else "",
},
.value => .{
Adapter.paramSql(index),
if (index < last_param_index) separator else "",
},
.assign => switch (context) {
.update => .{
Adapter.identifier(field.name),
Adapter.paramSql(index),
if (index < last_param_index) separator else "",
},
else => .{
Adapter.columnSql(field),
Adapter.paramSql(index),
if (index < last_param_index) separator else "",
},
},
};
const param = std.fmt.comptimePrint(template, args);
@memcpy(buf[cursor .. cursor + param.len], param);
cursor += param.len;
}
return buf;
}
fn fieldContext(
comptime field_infos: []const jetquery.fields.FieldInfo,
comptime index: usize,
comptime context: jetquery.fields.FieldContext,
) bool {
return if (field_infos.len > index)
field_infos[index].context == context
else
false;
}
fn lastParamIndex(
comptime field_infos: []const jetquery.fields.FieldInfo,
comptime context: jetquery.fields.FieldContext,
) usize {
var maybe_index: ?usize = null;
for (field_infos, 0..) |field, index| {
if (field.context == context) maybe_index = index;
}
if (maybe_index) |index|
return index
else
@compileError("No param matched for `" ++ @tagName(context) ++ "` query.");
}
fn hasParam(
comptime field_infos: []const jetquery.fields.FieldInfo,
comptime context: jetquery.fields.FieldContext,
) bool {
for (field_infos) |field| {
if (field.context == context) return true;
}
return false;
}

6
src/jetquery/util.zig Normal file
View File

@ -0,0 +1,6 @@
pub inline fn stringMaybeEnum(arg: anytype) []const u8 {
return switch (@typeInfo(@TypeOf(arg))) {
.enum_literal => @tagName(arg),
else => arg,
};
}