Fix DateTime coercion and optional DateTime detection

Update README with an example and feature list
This commit is contained in:
Bob Farrell 2024-11-26 19:22:01 +00:00
parent 5394d7cf5d
commit 147e20907e
7 changed files with 230 additions and 58 deletions

View File

@ -4,6 +4,68 @@ Database query library for [Jetzig](https://github.com/jetzig-framework/jetzig),
Documentation: [https://www.jetzig.dev/documentation/sections/database/introduction](https://www.jetzig.dev/documentation/sections/database/introduction)
## Features
* Comptime SQL generation
* PostgreSQL adapter ([pg.zig](https://github.com/karlseguin/pg.zig))
* Powerful `WHERE` clause syntax
* Object Relational Mapper (ORM)
* Migrations
* Relations/Associations
Use [standalone](https://www.jetzig.dev/documentation/sections/database/standalone_usage) or with [Jetzig](https://www.jetzig.dev/).
```zig
const Schema = struct {
pub const Cat = Model(
@This(),
"cats",
struct {
id: i32,
name: []const u8,
age: i32,
favorite_sport: []const u8,
status: []const u8,
},
.{ .relations = .{ .homes = hasMany(.Home, .{}) } },
);
pub const Home = Model(@This(), "homes", struct { id: i32, cat_id: i32, zip_code: []const u8 }, .{});
};
const query = Query(.postgresql, Schema, .Cat)
.join(.inner, .homes)
.where(.{
.{ .name = "Hercules" }, .OR, .{ .name = "Heracles" },
.{ .{ .age, .gt, 4 }, .{ .age, .lt, 10 } },
.{ .favorite_sport, .like, "%ball" },
.{ .favorite_sport, .not_eql, "basketball" },
.{ "my_sql_function(age)", .eql, 100 },
.{ .NOT, .{ .{ .age = 1 }, .OR, .{ .age = 2 } } },
.{ "age / paws = ? or age * paws < ?", .{ 2, 10 } },
.{ .{ .status = null }, .OR, .{ .status = [_][]const u8{ "sleeping", "eating" } } },
.{ .homes = .{ .zip_code = "10304" } },
});
var repo = try Repo(.postgresql, Schema).init(std.testing.allocator, .{
.adapter = .{
.database = "example_database",
.hostname = "127.0.0.1",
.port = 5432,
.username = "postgres",
.password = "password",
},
});
for (try repo.all(query)) |cat| {
std.debug.print("{s} lives in these ZIP codes:\n", .{cat.name});
for (cat.homes) |home| {
std.debug.print("{s}\n", .{home.zip_code});
}
}
```
## Testing
Use the provided _Docker Compose_ configuration to launch a local test database:

View File

@ -412,7 +412,7 @@ fn Statement(
context,
self.field_infos.len,
);
const clause_values = tree.values(args);
const clause_values = tree.values(Adapter, args);
const arg_values = clause_values.values;
const arg_errors = clause_values.errors;
@ -620,33 +620,33 @@ fn Statement(
pub fn update(self: Self, args: anytype) Statement(Adapter, .update, Schema, Model, .{
.field_infos = &(jetquery.fields.fieldInfos(Adapter, Model, &.{}, @TypeOf(args), .update) ++
timestampsFields(Model, .update)),
timestampsFields(Adapter, Model, .update)),
}) {
const S = Statement(Adapter, .update, Schema, Model, .{
.field_infos = &(jetquery.fields.fieldInfos(Adapter, Model, &.{}, @TypeOf(args), .update) ++
timestampsFields(Model, .update)),
timestampsFields(Adapter, Model, .update)),
});
return self.extend(S, args, .update);
}
pub fn updateAll(self: Self, args: anytype) Statement(Adapter, .update_all, Schema, Model, .{
.field_infos = &(jetquery.fields.fieldInfos(Adapter, Model, &.{}, @TypeOf(args), .update) ++
timestampsFields(Model, .update)),
timestampsFields(Adapter, Model, .update)),
}) {
const S = Statement(Adapter, .update_all, Schema, Model, .{
.field_infos = &(jetquery.fields.fieldInfos(Adapter, Model, &.{}, @TypeOf(args), .update) ++
timestampsFields(Model, .update)),
timestampsFields(Adapter, Model, .update)),
});
return self.extend(S, args, .update);
}
pub fn insert(self: Self, args: anytype) Statement(Adapter, .insert, Schema, Model, .{
.field_infos = &(jetquery.fields.fieldInfos(Adapter, Model, &.{}, @TypeOf(args), .insert) ++
timestampsFields(Model, .insert)),
timestampsFields(Adapter, Model, .insert)),
}) {
const S = Statement(Adapter, .insert, Schema, Model, .{
.field_infos = &(jetquery.fields.fieldInfos(Adapter, Model, &.{}, @TypeOf(args), .insert) ++
timestampsFields(Model, .insert)),
timestampsFields(Adapter, Model, .insert)),
});
return self.extend(S, args, .insert);
}
@ -1309,6 +1309,7 @@ fn Statement(
}
fn timestampsFields(
Adapter: type,
Model: type,
comptime field_context: jetquery.fields.FieldContext,
) [timestampsSize(Model, field_context)]jetquery.fields.FieldInfo {
@ -1322,7 +1323,7 @@ fn timestampsFields(
field_context,
);
const created_at = jetquery.fields.fieldInfo(
jetquery.fields.structField(jetquery.default_column_names.created_at, i64),
jetquery.fields.structField(jetquery.default_column_names.created_at, Adapter.DateTimePrimitive),
Model,
jetquery.default_column_names.created_at,
field_context,

View File

@ -1454,6 +1454,51 @@ test "alterTable" {
try std.testing.expect(dogs2.len == 0); // Empty table but valid select columns.
}
test "optional DateTime" {
try resetDatabase();
const Schema = struct {
pub const Thing = jetquery.Model(
@This(),
"things",
struct {
a: ?jetquery.DateTime,
b: ?jetquery.DateTime,
},
.{},
);
};
var repo = try Repo(.postgresql, Schema).init(std.testing.allocator, .{
.adapter = .{
.database = "repo_test",
.username = "postgres",
.hostname = "127.0.0.1",
.password = "password",
.port = 5432,
},
});
defer repo.deinit();
try repo.createTable("things", &.{
jetquery.schema.table.column("a", .datetime, .{ .optional = true }),
jetquery.schema.table.column("b", .datetime, .{ .optional = true }),
}, .{});
const now = jetquery.DateTime.now();
try repo.insert(.Thing, .{
.a = now,
.b = null,
});
if (try repo.Query(.Thing).findBy(.{ .a = now }).execute(&repo)) |thing| {
defer repo.free(thing);
try std.testing.expect(thing.a != null);
try std.testing.expect(thing.a.?.eql(now));
try std.testing.expect(thing.b == null);
} else try std.testing.expect(false);
}
fn resetDatabase() !void {
var repo = try Repo(.postgresql, void).init(
std.testing.allocator,

View File

@ -13,6 +13,7 @@ options: Options,
connected: bool,
lazy_connect: bool = false,
pub const DateTimePrimitive = i64;
pub const Count = i64;
pub const Average = i64;
pub const Sum = i64;
@ -128,8 +129,15 @@ fn resolvedValue(
[]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),
?jetquery.DateTime => if (row.get(?DateTimePrimitive, column_info.index)) |timestamp|
try jetquery.DateTime.fromUnix(
timestamp,
.microseconds,
)
else
null,
jetquery.DateTime => |T| try T.fromUnix(
row.get(DateTimePrimitive, column_info.index),
.microseconds,
),
else => |T| @compileError("Unsupported type: " ++ @typeName(T)),
@ -732,7 +740,7 @@ fn bindCoerce(value: anytype) BindCoerce(@TypeOf(value)) {
fn BindCoerce(T: type) type {
return switch (T) {
jetquery.DateTime => i64,
jetquery.DateTime => DateTimePrimitive,
else => T,
};
}

View File

@ -5,10 +5,11 @@ const jetcommon = @import("jetcommon");
const fields = @import("fields.zig");
pub fn coerce(
Adapter: type,
Table: type,
field_info: fields.FieldInfo,
value: anytype,
) CoercedValue(fields.ColumnType(Table, field_info), @TypeOf(value)) {
) CoercedValue(Adapter, fields.ColumnType(Adapter, Table, field_info), @TypeOf(value)) {
switch (field_info.context) {
.limit, .offset => return switch (@typeInfo(@TypeOf(value))) {
.int, .comptime_int => .{ .value = value },
@ -17,28 +18,29 @@ pub fn coerce(
else => {},
}
const T = fields.ColumnType(Table, field_info);
const T = fields.ColumnType(Adapter, Table, field_info);
if (T == jetcommon.types.DateTime) return value.microseconds();
if (@TypeOf(value) == jetcommon.types.DateTime) return .{ .value = value.microseconds() };
if (@TypeOf(value) == ?jetcommon.types.DateTime) return if (value) .{ .value = value.microseconds() } else null;
return switch (@typeInfo(@TypeOf(value))) {
.null => .{ .value = null },
.int, .comptime_int => switch (@typeInfo(T)) {
.int => .{ .value = @intCast(value) },
.bool => .{ .value = value == 1 },
else => coerceDelegate(T, value),
else => coerceDelegate(Adapter, T, value),
},
.float, .comptime_float => switch (@typeInfo(T)) {
.float => .{ .value = @floatCast(value) },
.bool => .{ .value = value == 1.0 },
else => coerceDelegate(T, value),
else => coerceDelegate(Adapter, T, value),
},
.bool => switch (@typeInfo(T)) {
.bool => .{ .value = value },
else => if (comptime canCoerceDelegate(@TypeOf(value)))
coerceDelegate(T, value.*)
coerceDelegate(Adapter, T, value.*)
else
coerceBool(T, value),
coerceBool(Adapter, T, value),
},
.pointer => |info| switch (@typeInfo(T)) {
.int => switch (@typeInfo(info.child)) {
@ -47,15 +49,15 @@ pub fn coerce(
// 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)
coerceInt(Adapter, T, value)
else
.{ .value = value },
else => .{ .value = value },
},
else => if (comptime canCoerceDelegate(info.child))
coerceDelegate(T, value.*)
coerceDelegate(Adapter, T, value.*)
else
coerceInt(T, value),
coerceInt(Adapter, T, value),
},
.float => switch (@typeInfo(info.child)) {
.float => switch (info.size) {
@ -63,9 +65,9 @@ pub fn coerce(
else => .{ .value = value },
},
else => if (comptime canCoerceDelegate(info.child))
coerceDelegate(T, value.*)
coerceDelegate(Adapter, T, value.*)
else
coerceFloat(T, value),
coerceFloat(Adapter, T, value),
},
.bool => switch (@typeInfo(info.child)) {
.bool => switch (info.size) {
@ -75,9 +77,9 @@ pub fn coerce(
.int, .comptime_int => .{ .value = value.* == 1 },
.float, .comptime_float => .{ .value = value.* == 1.0 },
else => if (comptime canCoerceDelegate(info.child))
coerceDelegate(T, value.*)
coerceDelegate(Adapter, T, value.*)
else
coerceBool(T, value),
coerceBool(Adapter, T, value),
},
.pointer => if (comptime canCoerceDelegate(info.child))
coerceDelegate(T, value.*)
@ -90,7 +92,7 @@ pub fn coerce(
@typeName(T) ++ "` and `" ++ @typeName(info.child) ++ "`"),
},
.array => .{ .value = &value },
else => coerceDelegate(T, value),
else => coerceDelegate(Adapter, T, value),
};
}
@ -100,7 +102,11 @@ pub fn coerce(
// 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)) {
pub fn coerceDelegate(
Adapter: type,
Target: type,
value: anytype,
) CoercedValue(Adapter, Target, @TypeOf(value)) {
const Source = @TypeOf(value);
if (comptime canCoerceDelegate(Source)) {
const coerced = value.toJetQuery(Target) catch |err| {
@ -120,7 +126,7 @@ pub fn canCoerceDelegate(T: type) bool {
};
}
pub fn CoercedValue(Target: type, Source: type) type {
pub fn CoercedValue(Adapter: type, Target: type, Source: type) type {
const T = switch (@typeInfo(Source)) {
.null => @TypeOf(null),
.pointer => |info| if (info.child == Target and info.size == .Slice)
@ -128,7 +134,13 @@ pub fn CoercedValue(Target: type, Source: type) type {
else
Target,
.array => |info| if (info.child == Target) []const Target else Target,
else => Target,
// TODO
else => if (Source == jetcommon.DateTime)
Adapter.DateTimePrimitive
else if (Source == ?jetcommon.DateTime)
?Adapter.DateTimePrimitive
else
Target,
};
return struct {
@ -137,7 +149,7 @@ pub fn CoercedValue(Target: type, Source: type) type {
};
}
fn coerceInt(T: type, value: []const u8) CoercedValue(T, @TypeOf(value)) {
fn coerceInt(Adapter: type, T: type, value: []const u8) CoercedValue(Adapter, T, @TypeOf(value)) {
const coerced = std.fmt.parseInt(T, value, 10) catch |err| {
return .{
.err = switch (err) {
@ -148,7 +160,7 @@ fn coerceInt(T: type, value: []const u8) CoercedValue(T, @TypeOf(value)) {
return .{ .value = coerced };
}
fn coerceFloat(T: type, value: []const u8) CoercedValue(T, @TypeOf(value)) {
fn coerceFloat(Adapter: type, T: type, value: []const u8) CoercedValue(Adapter, T, @TypeOf(value)) {
const coerced = std.fmt.parseFloat(T, value) catch |err| {
return .{
.err = switch (err) {
@ -159,7 +171,7 @@ fn coerceFloat(T: type, value: []const u8) CoercedValue(T, @TypeOf(value)) {
return .{ .value = coerced };
}
fn coerceBool(T: type, value: []const u8) CoercedValue(T, @TypeOf(value)) {
fn coerceBool(Adapter: type, T: type, value: []const u8) CoercedValue(Adapter, T, @TypeOf(value)) {
if (value.len != 1) return .{ .err = error.JetQueryInvalidBooleanString };
const maybe_boolean = switch (value[0]) {

View File

@ -62,7 +62,7 @@ pub fn FieldValues(Table: type, relations: []const type, comptime fields: []cons
});
}
pub fn ColumnType(Table: type, comptime field_info: FieldInfo) type {
pub fn ColumnType(Adapter: type, Table: type, comptime field_info: FieldInfo) type {
switch (field_info.context) {
.limit, .offset => return usize,
.where, .update, .insert, .order, .none => {},
@ -70,7 +70,12 @@ pub fn ColumnType(Table: type, comptime field_info: FieldInfo) type {
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;
return if (FT == jetcommon.types.DateTime)
Adapter.DateTimePrimitive
else if (FT == ?jetcommon.types.DateTime)
?Adapter.DateTimePrimitive
else
FT;
} else {
// We only arrive here when we process triplets, e.g.
// `.{ .foo, .lt_eql, 100 }`

View File

@ -4,6 +4,7 @@ const fields = @import("../fields.zig");
const coercion = @import("../coercion.zig");
const columns = @import("../columns.zig");
const sql = @import("../sql.zig");
const DateTime = @import("jetcommon").types.DateTime;
const Where = @This();
@ -60,7 +61,7 @@ pub const Tree = struct {
}
}
pub fn values(comptime self: Tree, args: anytype) ClauseValues(self.ValuesTuple, self.ErrorsTuple) {
pub fn values(comptime self: Tree, Adapter: type, args: anytype) ClauseValues(self.ValuesTuple, self.ErrorsTuple) {
var vals: self.ValuesTuple = undefined;
var errors: self.ErrorsTuple = undefined;
if (@typeInfo(@TypeOf(args)) != .@"struct") @compileError(
@ -68,6 +69,7 @@ pub const Tree = struct {
);
assignValues(
args,
Adapter,
self.ValuesTuple,
&vals,
self.ErrorsTuple,
@ -79,10 +81,6 @@ pub const Tree = struct {
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();
}
@ -115,8 +113,8 @@ pub fn tree(
.Table = Table,
.relations = relations,
.values_count = root.countValues(),
.values_fields = &root.values_fields(Table, relations),
.ValuesTuple = root.ValuesTuple(),
.values_fields = &root.values_fields(Adapter, Table, relations),
.ValuesTuple = root.ValuesTuple(Adapter),
.ErrorsTuple = root.ErrorsTuple(),
};
}
@ -134,18 +132,18 @@ pub const Node = union(enum) {
index: usize,
synthetic: bool = false,
pub fn ColumnType(self: Value) type {
const T = fields.ColumnType(self.Table, fields.fieldInfo(
pub fn ColumnType(self: Value, Adapter: type) type {
const T = fields.ColumnType(Adapter, self.Table, fields.fieldInfo(
self.field_info,
self.Table,
self.name,
self.field_context,
));
return if (self.isArray()) []const T else T;
return if (self.isArray(Adapter)) []const T else T;
}
pub fn isArray(self: Value) bool {
const T = fields.ColumnType(self.Table, fields.fieldInfo(
pub fn isArray(self: Value, Adapter: type) bool {
const T = fields.ColumnType(Adapter, self.Table, fields.fieldInfo(
self.field_info,
self.Table,
self.name,
@ -286,7 +284,7 @@ pub const Node = union(enum) {
prefix,
Adapter.identifier(value.Table.name),
Adapter.identifier(value.name),
if (value.isArray())
if (value.isArray(Adapter))
// 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
@ -366,16 +364,17 @@ pub const Node = union(enum) {
}
}
fn ValuesTuple(comptime self: Node) type {
fn ValuesTuple(comptime self: Node, Adapter: type) type {
const len = self.countValues();
var types: [len]type = undefined;
var index: usize = 0;
appendValueType(self, len, &types, &index);
appendValueType(Adapter, self, len, &types, &index);
return std.meta.Tuple(&types);
}
fn appendValueType(
Adapter: type,
comptime node: Node,
comptime len: usize,
types: *[len]type,
@ -385,12 +384,12 @@ pub const Node = union(enum) {
.condition => {},
.value => |value| {
if (!value.isNull()) {
types[index.*] = value.ColumnType();
types[index.*] = value.ColumnType(Adapter);
index.* += 1;
}
},
.group => |group| {
for (group.children) |child| appendValueType(child, len, types, index);
for (group.children) |child| appendValueType(Adapter, child, len, types, index);
},
.triplet => |triplet| {
switch (triplet.lhs) {
@ -420,7 +419,7 @@ pub const Node = union(enum) {
.{sql_string.sql},
));
}
appendValueType(value_node, len, types, index);
appendValueType(Adapter, value_node, len, types, index);
}
},
}
@ -435,11 +434,16 @@ pub const Node = union(enum) {
return std.meta.Tuple(&types);
}
fn values_fields(comptime self: Node, Table: type, relations: []const type) [self.countValues()]Field {
fn values_fields(
comptime self: Node,
Adapter: type,
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);
appendField(self, Adapter, Table, relations, len, &fields_array, &tuple_index);
return fields_array;
}
@ -467,6 +471,7 @@ pub const Node = union(enum) {
fn appendField(
node: Node,
Adapter: type,
Table: type,
relations: []const type,
comptime len: usize,
@ -476,11 +481,11 @@ pub const Node = union(enum) {
switch (node) {
.condition => {},
.value => |value| {
appendValueField(value.ColumnType(), value, len, fields_array, tuple_index);
appendValueField(value.ColumnType(Adapter), value, len, fields_array, tuple_index);
},
.group => |group| {
for (group.children) |child| {
appendField(child, Table, relations, len, fields_array, tuple_index);
appendField(child, Adapter, Table, relations, len, fields_array, tuple_index);
}
},
.triplet => |triplet| {
@ -574,7 +579,20 @@ fn nodeTree(
}
return switch (@typeInfo(T)) {
.@"struct" => |info| if (isTriplet(T)) blk: {
.@"struct" => |info| if (T == DateTime) blk: {
const value = Node.Value{
.field_context = field_context,
.Table = findRelation(Table, relations, path),
.name = name,
.type = Adapter.DateTimePrimitive,
.source_type = T,
.field_info = field_info,
.path = makePath(path, null),
.index = value_index.*,
};
value_index.* += 1;
break :blk .{ .value = value };
} else if (isTriplet(T)) blk: {
break :blk .{ .triplet = makeTriplet(
Adapter,
Table,
@ -711,6 +729,7 @@ fn findRelation(Table: type, relations: []const type, comptime path: []const []c
fn assignValues(
arg: anytype,
Adapter: type,
ValuesTuple: type,
values_tuple: *ValuesTuple,
ErrorsTuple: type,
@ -722,6 +741,7 @@ fn assignValues(
if (comptime coercion.canCoerceDelegate(@TypeOf(arg))) {
assignValue(
arg,
Adapter,
ValuesTuple,
values_tuple,
ErrorsTuple,
@ -737,9 +757,22 @@ fn assignValues(
.@"struct" => |info| {
// 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 isSqlStringWithArgsArray(@TypeOf(arg))) {
if (comptime @TypeOf(arg) == DateTime) {
assignValue(
arg,
Adapter,
ValuesTuple,
values_tuple,
ErrorsTuple,
errors_tuple,
values_fields,
path,
coerce,
);
} else if (comptime isSqlStringWithArgsArray(@TypeOf(arg))) {
assignValues(
arg[1],
Adapter,
ValuesTuple,
values_tuple,
ErrorsTuple,
@ -755,6 +788,7 @@ fn assignValues(
// index 2).
assignValues(
@field(arg, field.name)[2],
Adapter,
ValuesTuple,
values_tuple,
ErrorsTuple,
@ -767,6 +801,7 @@ fn assignValues(
// Recurse to evaluate whatever exists inside this struct.
assignValues(
@field(arg, field.name),
Adapter,
ValuesTuple,
values_tuple,
ErrorsTuple,
@ -792,6 +827,7 @@ fn assignValues(
else => {
assignValue(
arg,
Adapter,
ValuesTuple,
values_tuple,
ErrorsTuple,
@ -806,6 +842,7 @@ fn assignValues(
fn assignValue(
arg: anytype,
Adapter: type,
ValuesTuple: type,
values_tuple: *ValuesTuple,
ErrorsTuple: type,
@ -836,9 +873,11 @@ fn assignValue(
);
if (comptime coerce) {
const coerced: coercion.CoercedValue(
Adapter,
value_field.column_type,
@TypeOf(arg),
) = coercion.coerce(
Adapter,
value_field.Table,
field_info,
arg,