Narrowing down error sets
This commit is contained in:
parent
ab96b3b734
commit
91c116a303
3 changed files with 131 additions and 48 deletions
|
@ -3,6 +3,8 @@ const builtin = @import("builtin");
|
||||||
const util = @import("util");
|
const util = @import("util");
|
||||||
const models = @import("../db/models.zig");
|
const models = @import("../db/models.zig");
|
||||||
|
|
||||||
|
const DbError = @import("../db.zig").ExecError;
|
||||||
|
|
||||||
const getRandom = @import("../api.zig").getRandom;
|
const getRandom = @import("../api.zig").getRandom;
|
||||||
|
|
||||||
const Uuid = util.Uuid;
|
const Uuid = util.Uuid;
|
||||||
|
@ -11,8 +13,7 @@ const CreateError = error{
|
||||||
InvalidOrigin,
|
InvalidOrigin,
|
||||||
UnsupportedScheme,
|
UnsupportedScheme,
|
||||||
CommunityExists,
|
CommunityExists,
|
||||||
DbError,
|
} || DbError;
|
||||||
};
|
|
||||||
|
|
||||||
pub const Scheme = enum {
|
pub const Scheme = enum {
|
||||||
https,
|
https,
|
||||||
|
@ -65,11 +66,11 @@ pub fn create(db: anytype, origin: []const u8, name: ?[]const u8) CreateError!Co
|
||||||
.scheme = scheme,
|
.scheme = scheme,
|
||||||
};
|
};
|
||||||
|
|
||||||
if ((db.execRow2(&.{Uuid}, "SELECT id FROM community WHERE host = ?", .{host}, null) catch return error.DbError) != null) {
|
if ((try db.execRow2(&.{Uuid}, "SELECT id FROM community WHERE host = ?", .{host}, null)) != null) {
|
||||||
return error.CommunityExists;
|
return error.CommunityExists;
|
||||||
}
|
}
|
||||||
|
|
||||||
db.insert2("community", community) catch return error.DbError;
|
try db.insert2("community", community);
|
||||||
|
|
||||||
return community;
|
return community;
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,11 +35,10 @@ fn readRow(comptime RowTuple: type, row: sql.Row, allocator: ?std.mem.Allocator)
|
||||||
|
|
||||||
pub fn ResultSet(comptime result_types: []const type) type {
|
pub fn ResultSet(comptime result_types: []const type) type {
|
||||||
return struct {
|
return struct {
|
||||||
pub const QueryError = anyerror;
|
|
||||||
pub const Row = std.meta.Tuple(result_types);
|
pub const Row = std.meta.Tuple(result_types);
|
||||||
|
|
||||||
_stmt: sql.PreparedStmt,
|
_stmt: sql.PreparedStmt,
|
||||||
err: ?QueryError = null,
|
err: ?ExecError = null,
|
||||||
|
|
||||||
pub fn finish(self: *@This()) void {
|
pub fn finish(self: *@This()) void {
|
||||||
self._stmt.finalize();
|
self._stmt.finalize();
|
||||||
|
@ -217,6 +216,8 @@ fn getEnum(row: sql.Row, comptime T: type, idx: u15, alloc: std.mem.Allocator) !
|
||||||
return error.UnknownTag;
|
return error.UnknownTag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const ExecError = sql.PrepareError || sql.RowGetError || sql.BindError || std.mem.Allocator.Error || error{AllocatorRequired};
|
||||||
|
|
||||||
pub const Database = struct {
|
pub const Database = struct {
|
||||||
db: sql.Sqlite,
|
db: sql.Sqlite,
|
||||||
|
|
||||||
|
@ -238,7 +239,7 @@ pub const Database = struct {
|
||||||
comptime result_types: []const type,
|
comptime result_types: []const type,
|
||||||
comptime q: []const u8,
|
comptime q: []const u8,
|
||||||
args: anytype,
|
args: anytype,
|
||||||
) !ResultSet(result_types) {
|
) ExecError!ResultSet(result_types) {
|
||||||
std.log.debug("executing sql:\n===\n{s}\n===", .{q});
|
std.log.debug("executing sql:\n===\n{s}\n===", .{q});
|
||||||
|
|
||||||
const stmt = try self.db.prepare(q);
|
const stmt = try self.db.prepare(q);
|
||||||
|
@ -259,7 +260,7 @@ pub const Database = struct {
|
||||||
comptime q: []const u8,
|
comptime q: []const u8,
|
||||||
args: anytype,
|
args: anytype,
|
||||||
allocator: ?std.mem.Allocator,
|
allocator: ?std.mem.Allocator,
|
||||||
) !?ResultSet(result_types).Row {
|
) ExecError!?ResultSet(result_types).Row {
|
||||||
var results = try self.exec2(result_types, q, args);
|
var results = try self.exec2(result_types, q, args);
|
||||||
defer results.finish();
|
defer results.finish();
|
||||||
|
|
||||||
|
@ -290,7 +291,7 @@ pub const Database = struct {
|
||||||
self: *Database,
|
self: *Database,
|
||||||
comptime table: []const u8,
|
comptime table: []const u8,
|
||||||
value: anytype,
|
value: anytype,
|
||||||
) !void {
|
) ExecError!void {
|
||||||
const ValueType = comptime @TypeOf(value);
|
const ValueType = comptime @TypeOf(value);
|
||||||
const table_spec = comptime table ++ build_field_list(ValueType, null);
|
const table_spec = comptime table ++ build_field_list(ValueType, null);
|
||||||
const value_spec = comptime build_field_list(ValueType, "?");
|
const value_spec = comptime build_field_list(ValueType, "?");
|
||||||
|
|
159
src/sql/lib.zig
159
src/sql/lib.zig
|
@ -7,31 +7,104 @@ const c = @cImport({
|
||||||
const Uuid = util.Uuid;
|
const Uuid = util.Uuid;
|
||||||
const DateTime = util.DateTime;
|
const DateTime = util.DateTime;
|
||||||
|
|
||||||
|
const UnexpectedError = error{Unexpected};
|
||||||
|
|
||||||
|
pub const OpenError = error{
|
||||||
|
DbCorrupt,
|
||||||
|
BadPathName,
|
||||||
|
IsDir,
|
||||||
|
InputOutput,
|
||||||
|
NoSpaceLeft,
|
||||||
|
} || UnexpectedError;
|
||||||
|
pub const PrepareError = UnexpectedError;
|
||||||
|
pub const RowGetError = error{ InvalidData, StreamTooLong, OutOfMemory } || UnexpectedError;
|
||||||
|
pub const BindError = UnexpectedError;
|
||||||
|
|
||||||
|
fn getCharPos(text: []const u8, offset: c_int) struct { row: usize, col: usize } {
|
||||||
|
var row: usize = 0;
|
||||||
|
var col: usize = 0;
|
||||||
|
var i: usize = 0;
|
||||||
|
|
||||||
|
if (offset > text.len) return .{ .row = 0, .col = 0 };
|
||||||
|
|
||||||
|
while (i != offset) : (i += 1) {
|
||||||
|
if (text[i] == '\n') {
|
||||||
|
row += 1;
|
||||||
|
col = 0;
|
||||||
|
} else {
|
||||||
|
col += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return .{ .row = row, .col = col };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handleUnexpectedError(db: *c.sqlite3, code: c_int, sql_text: ?[]const u8) UnexpectedError {
|
||||||
|
std.log.err("Unexpected error in SQLite engine: {s} ({})", .{ c.sqlite3_errstr(code), code });
|
||||||
|
|
||||||
|
std.log.debug("Additional details:", .{});
|
||||||
|
std.log.debug("{?s}", .{c.sqlite3_errmsg(db)});
|
||||||
|
if (sql_text) |sql| {
|
||||||
|
const byte_offset = c.sqlite3_error_offset(db);
|
||||||
|
if (byte_offset >= 0) {
|
||||||
|
const pos = getCharPos(sql, byte_offset);
|
||||||
|
std.log.debug("Failed at char ({}:{}) of SQL:\n{s}", .{ pos.row, pos.col, sql });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std.log.debug("{?s}", .{@errorReturnTrace()});
|
||||||
|
|
||||||
|
return error.Unexpected;
|
||||||
|
}
|
||||||
|
|
||||||
pub const Sqlite = struct {
|
pub const Sqlite = struct {
|
||||||
db: *c.sqlite3,
|
db: *c.sqlite3,
|
||||||
|
|
||||||
pub fn open(path: [:0]const u8) !Sqlite {
|
pub fn open(path: [:0]const u8) OpenError!Sqlite {
|
||||||
|
const flags = c.SQLITE_OPEN_READWRITE | c.SQLITE_OPEN_CREATE | c.SQLITE_OPEN_EXRESCODE;
|
||||||
|
|
||||||
var db: ?*c.sqlite3 = undefined;
|
var db: ?*c.sqlite3 = undefined;
|
||||||
const err = c.sqlite3_open_v2(@ptrCast([*c]const u8, path), &db, c.SQLITE_OPEN_READWRITE | c.SQLITE_OPEN_CREATE, null);
|
switch (c.sqlite3_open_v2(@ptrCast([*c]const u8, path), &db, flags, null)) {
|
||||||
if (err != c.SQLITE_OK) return error.UnknownError;
|
c.SQLITE_OK => {},
|
||||||
|
c.SQLITE_NOTADB, c.SQLITE_CORRUPT => return error.DbCorrupt,
|
||||||
|
c.SQLITE_IOERR_WRITE, c.SQLITE_IOERR_READ => return error.InputOutput,
|
||||||
|
c.SQLITE_CANTOPEN_ISDIR => return error.IsDir,
|
||||||
|
c.SQLITE_CANTOPEN_FULLPATH => return error.BadPathName,
|
||||||
|
c.SQLITE_FULL => return error.NoSpaceLeft,
|
||||||
|
|
||||||
|
else => |err| {
|
||||||
|
std.log.err(
|
||||||
|
\\Unable to open SQLite DB "{s}"
|
||||||
|
\\Error: {s} ({})
|
||||||
|
, .{ path, c.sqlite3_errstr(err), err });
|
||||||
|
|
||||||
|
return error.Unexpected;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return Sqlite{
|
return Sqlite{
|
||||||
.db = db.?,
|
.db = db.?,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn close(self: *Sqlite) void {
|
pub fn close(self: Sqlite) void {
|
||||||
_ = c.sqlite3_close_v2(self.db);
|
switch (c.sqlite3_close(self.db)) {
|
||||||
|
c.SQLITE_OK => {},
|
||||||
|
|
||||||
|
c.SQLITE_BUSY => {
|
||||||
|
std.log.err("SQLite DB could not be closed as it is busy.\n{s}", .{c.sqlite3_errmsg(self.db)});
|
||||||
|
},
|
||||||
|
|
||||||
|
else => |err| {
|
||||||
|
std.log.err("Could not close SQLite DB", .{});
|
||||||
|
handleUnexpectedError(self.db, err, null) catch {};
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn prepare(self: *Sqlite, sql: []const u8) !PreparedStmt {
|
pub fn prepare(self: *Sqlite, sql: []const u8) PrepareError!PreparedStmt {
|
||||||
var stmt: ?*c.sqlite3_stmt = undefined;
|
var stmt: ?*c.sqlite3_stmt = undefined;
|
||||||
const err = c.sqlite3_prepare_v2(self.db, sql.ptr, @intCast(c_int, sql.len), &stmt, null);
|
const err = c.sqlite3_prepare_v2(self.db, sql.ptr, @intCast(c_int, sql.len), &stmt, null);
|
||||||
if (err != c.SQLITE_OK) {
|
if (err != c.SQLITE_OK) return handleUnexpectedError(self.db, err, sql);
|
||||||
std.log.debug("sql error {}: {s}", .{ err, c.sqlite3_errmsg(self.db) });
|
|
||||||
std.log.debug("Failed on SQL:\n==========\n{s}\n==========", .{sql});
|
|
||||||
return error.UnknownError;
|
|
||||||
}
|
|
||||||
|
|
||||||
return PreparedStmt{ .stmt = stmt.?, .db = self.db };
|
return PreparedStmt{ .stmt = stmt.?, .db = self.db };
|
||||||
}
|
}
|
||||||
|
@ -41,15 +114,15 @@ pub const Row = struct {
|
||||||
stmt: *c.sqlite3_stmt,
|
stmt: *c.sqlite3_stmt,
|
||||||
db: *c.sqlite3,
|
db: *c.sqlite3,
|
||||||
|
|
||||||
pub fn isNull(self: Row, idx: u15) bool {
|
pub fn isNull(self: Row, idx: u15) RowGetError!bool {
|
||||||
return c.sqlite3_column_type(self.stmt, idx) == c.SQLITE_NULL;
|
return c.sqlite3_column_type(self.stmt, idx) == c.SQLITE_NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getI64(self: Row, idx: u15) !i64 {
|
pub fn getI64(self: Row, idx: u15) RowGetError!i64 {
|
||||||
return @intCast(i64, c.sqlite3_column_int64(self.stmt, idx));
|
return @intCast(i64, c.sqlite3_column_int64(self.stmt, idx));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getText(self: Row, idx: u15, buf: []u8) ![]u8 {
|
pub fn getText(self: Row, idx: u15, buf: []u8) RowGetError![]u8 {
|
||||||
const ptr = c.sqlite3_column_text(self.stmt, idx);
|
const ptr = c.sqlite3_column_text(self.stmt, idx);
|
||||||
|
|
||||||
const size = @intCast(usize, c.sqlite3_column_bytes(self.stmt, idx));
|
const size = @intCast(usize, c.sqlite3_column_bytes(self.stmt, idx));
|
||||||
|
@ -59,7 +132,7 @@ pub const Row = struct {
|
||||||
return buf[0..size];
|
return buf[0..size];
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getTextAlloc(self: Row, idx: u15, alloc: std.mem.Allocator) ![]u8 {
|
pub fn getTextAlloc(self: Row, idx: u15, alloc: std.mem.Allocator) RowGetError![]u8 {
|
||||||
const size = c.sqlite3_column_bytes(self.stmt, idx);
|
const size = c.sqlite3_column_bytes(self.stmt, idx);
|
||||||
var buf = try alloc.alloc(u8, @intCast(usize, size));
|
var buf = try alloc.alloc(u8, @intCast(usize, size));
|
||||||
errdefer alloc.free(buf);
|
errdefer alloc.free(buf);
|
||||||
|
@ -67,7 +140,7 @@ pub const Row = struct {
|
||||||
return self.getText(idx, buf);
|
return self.getText(idx, buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getBlob(self: Row, idx: u15, buf: []u8) ![]u8 {
|
pub fn getBlob(self: Row, idx: u15, buf: []u8) RowGetError![]u8 {
|
||||||
const ptr = @ptrCast([*]const u8, c.sqlite3_column_blob(self.stmt, idx));
|
const ptr = @ptrCast([*]const u8, c.sqlite3_column_blob(self.stmt, idx));
|
||||||
|
|
||||||
const size = @intCast(usize, c.sqlite3_column_bytes(self.stmt, idx));
|
const size = @intCast(usize, c.sqlite3_column_bytes(self.stmt, idx));
|
||||||
|
@ -77,7 +150,7 @@ pub const Row = struct {
|
||||||
return buf[0..size];
|
return buf[0..size];
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getBlobAlloc(self: Row, idx: u15, alloc: std.mem.Allocator) ![]u8 {
|
pub fn getBlobAlloc(self: Row, idx: u15, alloc: std.mem.Allocator) RowGetError![]u8 {
|
||||||
const size = c.sqlite3_column_bytes(self.stmt, idx);
|
const size = c.sqlite3_column_bytes(self.stmt, idx);
|
||||||
var buf = try alloc.alloc(u8, @intCast(usize, size));
|
var buf = try alloc.alloc(u8, @intCast(usize, size));
|
||||||
errdefer alloc.free(buf);
|
errdefer alloc.free(buf);
|
||||||
|
@ -85,13 +158,13 @@ pub const Row = struct {
|
||||||
return self.getBlob(idx, buf);
|
return self.getBlob(idx, buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getUuid(self: Row, idx: u15) !Uuid {
|
pub fn getUuid(self: Row, idx: u15) RowGetError!Uuid {
|
||||||
var buf: [Uuid.string_len + 1]u8 = undefined;
|
var buf: [Uuid.string_len + 1]u8 = undefined;
|
||||||
_ = try self.getText(idx, &buf);
|
_ = try self.getText(idx, &buf);
|
||||||
return try Uuid.parse(buf[0..Uuid.string_len]);
|
return Uuid.parse(buf[0..Uuid.string_len]) catch return error.InvalidData;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getDateTime(self: Row, idx: u15) !DateTime {
|
pub fn getDateTime(self: Row, idx: u15) RowGetError!DateTime {
|
||||||
return DateTime{ .seconds_since_epoch = try self.getI64(idx) };
|
return DateTime{ .seconds_since_epoch = try self.getI64(idx) };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -100,67 +173,75 @@ pub const PreparedStmt = struct {
|
||||||
stmt: *c.sqlite3_stmt,
|
stmt: *c.sqlite3_stmt,
|
||||||
db: *c.sqlite3,
|
db: *c.sqlite3,
|
||||||
|
|
||||||
pub fn bindNull(self: PreparedStmt, idx: u15) !void {
|
pub fn bindNull(self: PreparedStmt, idx: u15) BindError!void {
|
||||||
return switch (c.sqlite3_bind_null(self.stmt, idx)) {
|
return switch (c.sqlite3_bind_null(self.stmt, idx)) {
|
||||||
c.SQLITE_OK => {},
|
c.SQLITE_OK => {},
|
||||||
else => error.UnknownError,
|
else => |err| handleUnexpectedError(self.db, err, null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bindUuid(self: PreparedStmt, idx: u15, id: Uuid) !void {
|
pub fn bindUuid(self: PreparedStmt, idx: u15, id: Uuid) BindError!void {
|
||||||
const str = id.toCharArray();
|
const str = id.toCharArray();
|
||||||
return self.bindText(idx, &str);
|
return self.bindText(idx, &str);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bindText(self: PreparedStmt, idx: u15, str: []const u8) !void {
|
pub fn bindText(self: PreparedStmt, idx: u15, str: []const u8) BindError!void {
|
||||||
// Work around potential null pointer in empty string
|
// Work around potential null pointer in empty string
|
||||||
const eff_str = if (str.len == 0) (" ")[0..0] else str;
|
const eff_str = if (str.len == 0) (" ")[0..0] else str;
|
||||||
return switch (c.sqlite3_bind_text(self.stmt, idx, eff_str.ptr, @intCast(c_int, eff_str.len), c.SQLITE_TRANSIENT)) {
|
return switch (c.sqlite3_bind_text(self.stmt, idx, eff_str.ptr, @intCast(c_int, eff_str.len), c.SQLITE_TRANSIENT)) {
|
||||||
c.SQLITE_OK => {},
|
c.SQLITE_OK => {},
|
||||||
else => error.UnknownError,
|
else => |err| handleUnexpectedError(self.db, err, null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bindBlob(self: PreparedStmt, idx: u15, blob: []const u8) !void {
|
pub fn bindBlob(self: PreparedStmt, idx: u15, blob: []const u8) BindError!void {
|
||||||
return switch (c.sqlite3_bind_blob64(self.stmt, idx, blob.ptr, blob.len, c.SQLITE_TRANSIENT)) {
|
return switch (c.sqlite3_bind_blob64(self.stmt, idx, blob.ptr, blob.len, c.SQLITE_TRANSIENT)) {
|
||||||
c.SQLITE_OK => {},
|
c.SQLITE_OK => {},
|
||||||
else => error.UnknownError,
|
else => |err| handleUnexpectedError(self.db, err, null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bindI64(self: PreparedStmt, idx: u15, val: i64) !void {
|
pub fn bindI64(self: PreparedStmt, idx: u15, val: i64) BindError!void {
|
||||||
return switch (c.sqlite3_bind_int64(self.stmt, idx, val)) {
|
return switch (c.sqlite3_bind_int64(self.stmt, idx, val)) {
|
||||||
c.SQLITE_OK => {},
|
c.SQLITE_OK => {},
|
||||||
else => error.UnknownError,
|
else => |err| handleUnexpectedError(self.db, err, null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bindDateTime(self: PreparedStmt, idx: u15, val: DateTime) !void {
|
pub fn bindDateTime(self: PreparedStmt, idx: u15, val: DateTime) BindError!void {
|
||||||
return self.bindI64(idx, val.seconds_since_epoch);
|
return self.bindI64(idx, val.seconds_since_epoch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const StepError = UnexpectedError;
|
||||||
pub fn step(self: PreparedStmt) !?Row {
|
pub fn step(self: PreparedStmt) !?Row {
|
||||||
return switch (c.sqlite3_step(self.stmt)) {
|
return switch (c.sqlite3_step(self.stmt)) {
|
||||||
c.SQLITE_ROW => Row{ .stmt = self.stmt, .db = self.db },
|
c.SQLITE_ROW => Row{ .stmt = self.stmt, .db = self.db },
|
||||||
c.SQLITE_DONE => null,
|
c.SQLITE_DONE => null,
|
||||||
|
|
||||||
else => |err| blk: {
|
else => |err| handleUnexpectedError(self.db, err, self.getGeneratingSql()),
|
||||||
std.log.debug("sql error {}: {s}", .{ err, c.sqlite3_errmsg(self.db) });
|
|
||||||
std.log.debug("Failed on SQL:\n==========\n{?s}\n==========", .{self.getGeneratingSql()});
|
|
||||||
break :blk error.UnknownError;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn finalize(self: PreparedStmt) void {
|
pub fn finalize(self: PreparedStmt) void {
|
||||||
_ = c.sqlite3_finalize(self.stmt);
|
switch (c.sqlite3_finalize(self.stmt)) {
|
||||||
|
c.SQLITE_OK => {},
|
||||||
|
else => |err| {
|
||||||
|
handleUnexpectedError(self.db, err, self.getGeneratingSql()) catch {};
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset(self: PreparedStmt) void {
|
pub fn reset(self: PreparedStmt) void {
|
||||||
_ = c.sqlite3_reset(self.stmt);
|
switch (c.sqlite3_reset(self.stmt)) {
|
||||||
|
c.SQLITE_OK => {},
|
||||||
|
else => |err| {
|
||||||
|
handleUnexpectedError(self.db, err, self.getGeneratingSql()) catch {};
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn getGeneratingSql(self: PreparedStmt) ?[*:0]const u8 {
|
fn getGeneratingSql(self: PreparedStmt) ?[]const u8 {
|
||||||
return c.sqlite3_sql(self.stmt);
|
const ptr = c.sqlite3_sql(self.stmt) orelse return null;
|
||||||
|
return ptr[0..std.mem.len(ptr)];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue