From bb11117a6da6ded9bc64fcf66b41945035bb2601 Mon Sep 17 00:00:00 2001 From: Madeorsk Date: Sun, 8 Dec 2024 01:21:35 +0100 Subject: [PATCH] Initial project setup with a declarative API for models definition and their dispatchers. --- .gitignore | 6 ++ README.md | 29 +++++++ build.zig | 81 ++++++++++++++++++++ build.zig.zon | 22 ++++++ lib/api.zig | 100 ++++++++++++++++++++++++ lib/crud.zig | 77 +++++++++++++++++++ lib/dispatchers.zig | 127 +++++++++++++++++++++++++++++++ lib/http.zig | 100 ++++++++++++++++++++++++ lib/model.zig | 101 ++++++++++++++++++++++++ lib/registry.zig | 67 ++++++++++++++++ lib/relationships.zig | 89 ++++++++++++++++++++++ lib/root.zig | 20 +++++ lib/types.zig | 55 +++++++++++++ lib/utils/comptime.zig | 69 +++++++++++++++++ src/mql.zig | 6 ++ tests/example.zig | 94 +++++++++++++++++++++++ tests/models/account.zig | 20 +++++ tests/models/invoice.zig | 51 +++++++++++++ tests/models/invoice_product.zig | 30 ++++++++ tests/root.zig | 3 + 20 files changed, 1147 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 lib/api.zig create mode 100644 lib/crud.zig create mode 100644 lib/dispatchers.zig create mode 100644 lib/http.zig create mode 100644 lib/model.zig create mode 100644 lib/registry.zig create mode 100644 lib/relationships.zig create mode 100644 lib/root.zig create mode 100644 lib/types.zig create mode 100644 lib/utils/comptime.zig create mode 100644 src/mql.zig create mode 100644 tests/example.zig create mode 100644 tests/models/account.zig create mode 100644 tests/models/invoice.zig create mode 100644 tests/models/invoice_product.zig create mode 100644 tests/root.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2ae369 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# IDEA +*.iml + +# Zig +zig-out/ +.zig-cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..44890bd --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# PGMQL + +PGMQL (or _PostgreMQL_ or _Pro Gamer Master Query Language_) is a library designed to ease the creation of models APIs for backend and frontend uses. + +PGMQL features **model definition with relationships**, **CRUD operations**, **custom dispatchers definition**, **authentication** and **authorization**. All these features are based on a PostgreSQL database, which means that you're never stuck with PGMQL: you can interact with your data in other ways if you feel limited with PGMQL (its goal is **not** to replace _everything_). + +PGMQL uses Zig to provide very high performances and compile-time definitions of models, APIs and authorizations. + +## Features + +### Model definition + +With PGMQL, you can define your models in a _structure-oriented_ way. A model is a structure which can be related to other structures, through _relationships_. You can then ask PGMQL to retrieve the models with the required relationships. + +### CRUD operations + +PGMQL natively provide a full-featured REST API to perform CRUD operations on your models. For each defined model, you can easily create them with `POST` requests, read them with `GET` requests, update them with `PATCH` requests and delete them with `DELETE` requests. + +### Custom dispatchers + +In real-world applications, models often need to allow more operations than simple CRUD: with PGMQL, you can define custom dispatchers to perform multiple complex operations in a single transaction. + +### Authentication + +Your models can be protected to be accessible / editable by authenticated users only. Authenticated accounts can also bear some useful metadata for CRUD operations or dispatchers. + +### Authorization + +PGMQL allows you to define policies for every model: you can easily define access control for CRUD operations, but also custom dispatchers. diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..4c8d148 --- /dev/null +++ b/build.zig @@ -0,0 +1,81 @@ +const std = @import("std"); + +// Although this function looks imperative, note that its job is to +// declaratively construct a build graph that will be executed by an external +// runner. +pub fn build(b: *std.Build) void { + // Standard target options allows the person running `zig build` to choose + // what target to build for. Here we do not override the defaults, which + // means any target is allowed, and the default is native. Other options + // for restricting supported target set are available. + const target = b.standardTargetOptions(.{}); + + // Standard optimization options allow the person running `zig build` to select + // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not + // set a preferred release mode, allowing the user to decide how to optimize. + const optimize = b.standardOptimizeOption(.{}); + + // Create PGMQL module. + const pgmql = b.addModule("pgmql", .{ + .root_source_file = b.path("lib/root.zig"), + .target = target, + .optimize = optimize, + }); + + // Add tardy dependency. + const tardy = b.dependency("tardy", .{ + .target = target, + .optimize = optimize, + }); + pgmql.addImport("tardy", tardy.module("tardy")); + + // Add zzz dependency. + const zzz = b.dependency("zzz", .{ + .target = target, + .optimize = optimize, + }); + pgmql.addImport("zzz", zzz.module("zzz")); + + // Create MQL executable. + const mql = b.addExecutable(.{ + .name = "mql", + .root_source_file = b.path("src/mql.zig"), + .target = target, + .optimize = optimize, + }); + mql.root_module.addImport("pgmql", pgmql); + b.installArtifact(mql); + + // Create example executable. + const example = b.addExecutable(.{ + .name = "example", + .root_source_file = b.path("tests/example.zig"), + .target = target, + .optimize = optimize, + }); + example.root_module.addImport("pgmql", pgmql); + example.root_module.addImport("tardy", tardy.module("tardy")); + example.root_module.addImport("zzz", zzz.module("zzz")); + b.installArtifact(example); + + const lib_unit_tests = b.addTest(.{ + .root_source_file = b.path("lib/root.zig"), + .target = target, + .optimize = optimize, + }); + const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); + + const lib_example_tests = b.addTest(.{ + .root_source_file = b.path("tests/root.zig"), + .target = target, + .optimize = optimize, + }); + // Add pgmql dependency. + lib_example_tests.root_module.addImport("pgmql", pgmql); + const run_lib_example_tests = b.addRunArtifact(lib_example_tests); + run_lib_example_tests.has_side_effects = true; + + const test_step = b.step("test", "Run unit tests."); + test_step.dependOn(&run_lib_example_tests.step); + test_step.dependOn(&run_lib_unit_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..ce83b78 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,22 @@ +.{ + .name = "pgmql", + .version = "0.1.0", + + .dependencies = .{ + .zzz = .{ + .url = "git+https://github.com/mookums/zzz?ref=main#e7cc636a32a43b25d5e6ebde4f34e9e0a873fcb8", + .hash = "12200fd1e8c229eb5800fe3d2937ddf8f3daa02a65b9b894e227526b97b709513cba", + }, + .tardy = .{ + .url = "git+https://github.com/mookums/tardy?ref=v0.1.0#ae0970d6b3fa5b03625b14e142c664efe1fd7789", + .hash = "12207f5afee3b8933c1c32737e8feedc80a2e4feebe058739509094c812e4a8d2cc8", + }, + }, + + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + "README.md", + }, +} diff --git a/lib/api.zig b/lib/api.zig new file mode 100644 index 0000000..0b2a522 --- /dev/null +++ b/lib/api.zig @@ -0,0 +1,100 @@ +const std = @import("std"); +const _registry = @import("registry.zig"); +const _model = @import("model.zig"); +const _dispatchers = @import("dispatchers.zig"); + + +/// Type of dispatchers map. +pub const DispatchersMap = std.StaticStringMap(_dispatchers.Dispatcher); + +/// Type of models dispatchers map. +pub const ModelsDispatchersMap = std.StaticStringMap(DispatchersMap); + +/// Build dispatchers maps of all models from the dispatchers definition structure. +fn BuildModelsDispatchersMaps(comptime dispatchersDefinition: type) ModelsDispatchersMap { + return ModelsDispatchersMap.initComptime(comptime dispatchersKv: { + // Get all models dispatchers definition structures. + const modelsDispatchersDecls = std.meta.declarations(dispatchersDefinition); + + // Initialize a new key-values array for all models. + var dispatchersKv: [modelsDispatchersDecls.len]struct{[]const u8, DispatchersMap} = undefined; + + // Build all dispatchers maps and add them in the key-values array. + for (&dispatchersKv, modelsDispatchersDecls) |*dispatcherKv, modelDispatchersDecl| { + dispatcherKv.* = .{ modelDispatchersDecl.name, BuildDispatchersMap(modelDispatchersDecl.name, @field(dispatchersDefinition, modelDispatchersDecl.name)) }; + } + + // Return the new dispatchers maps key-values. + break :dispatchersKv dispatchersKv; + }); +} + +/// Build a dispatchers map for a model from its dispatchers definition structure. +fn BuildDispatchersMap(comptime modelName: []const u8, comptime dispatchersBuilders: type) DispatchersMap { + // Return the new dispatchers map. + return DispatchersMap.initComptime(comptime dispatchersKv: { + // Get all dispatchers declarations. + const dispatchersDecls = std.meta.declarations(dispatchersBuilders); + + // Initialize a new key-values array for all dispatchers. + var dispatchersKv: [dispatchersDecls.len]struct{[]const u8, _dispatchers.Dispatcher} = undefined; + + // Build all dispatchers and add them in the key-values array. + for (dispatchersKv[0..dispatchersDecls.len], dispatchersDecls) |*dispatcherKv, dispatcherDecl| { + dispatcherKv.* = .{ dispatcherDecl.name, @field(dispatchersBuilders, dispatcherDecl.name).build(modelName, dispatcherDecl.name) }; + } + + // Return the new dispatchers key-values. + break :dispatchersKv dispatchersKv; + }); +} + + +/// MQL API structure. +pub fn Api( + comptime registry: type, + comptime dispatchersDefinition: type, +) type { + // Build models dispatchers map. + const dispatchersMap = BuildModelsDispatchersMaps(dispatchersDefinition); + + // Compute dispatchers array size. + const dispatchersArraySize = comptime dispatchersArraySize: { + var size = 0; + + for (dispatchersMap.values()) |dispatchers| { + size += dispatchers.values().len; + } + + break :dispatchersArraySize size; + }; + // Build dispatchers array. + const dispatchersArray = comptime dispatchersArray: { + var dispatchersArray: [dispatchersArraySize]_dispatchers.Dispatcher = undefined; + var dispatchersArrayIndex = 0; + + for (dispatchersMap.values()) |dispatchers| { + for (dispatchers.values()) |dispatcher| { + dispatchersArray[dispatchersArrayIndex] = dispatcher; + dispatchersArrayIndex += 1; + } + } + + break :dispatchersArray dispatchersArray; + }; + + return struct { + pub const Registry = registry; + pub const dispatchers = dispatchersMap; + + /// Get model name from its provided instance. + pub fn getModelName(comptime model: _model.Model) []const u8 { + return Registry.getModelName(model); + } + + /// Get a list of all declared dispatchers. + pub fn getDispatchers() []const _dispatchers.Dispatcher { + return dispatchersArray[0..dispatchersArraySize]; + } + }; +} diff --git a/lib/crud.zig b/lib/crud.zig new file mode 100644 index 0000000..c007e54 --- /dev/null +++ b/lib/crud.zig @@ -0,0 +1,77 @@ +const std = @import("std"); +const zzz = @import("zzz"); + +/// CRUD dispatchers definitions for a given model dispatcher definition. +pub fn Crud(ModelDispatcherDefinition: type) type { + return struct { + /// Make a definition structure to define all CRUD operations with the provided policy. + pub fn All(policy: ModelDispatcherDefinition.Policy) type { + return struct { + pub usingnamespace Create(policy); + pub usingnamespace Read(policy); + pub usingnamespace Update(policy); + pub usingnamespace Delete(policy); + }; + } + + /// Make a definition structure to define model creation operation with the provided policy. + pub fn Create(policy: ModelDispatcherDefinition.Policy) type { + return struct { + pub const create = (ModelDispatcherDefinition{ + .policy = policy, + .run = struct { + fn f(context: ModelDispatcherDefinition.Context) void { + _ = context; //TODO! + } + }.f, + .httpMethod = zzz.HTTP.Method.POST, + }).builder(); + }; + } + + /// Make a definition structure to define model retrieval operation with the provided policy. + pub fn Read(policy: ModelDispatcherDefinition.Policy) type { + return struct { + pub const read = (ModelDispatcherDefinition{ + .policy = policy, + .run = struct { + fn f(context: ModelDispatcherDefinition.Context) void { + _ = context; //TODO! + } + }.f, + .httpMethod = zzz.HTTP.Method.GET, + }).builder(); + }; + } + + /// Make a definition structure to define model update operation with the provided policy. + pub fn Update(policy: ModelDispatcherDefinition.Policy) type { + return struct { + pub const update = (ModelDispatcherDefinition{ + .policy = policy, + .run = struct { + fn f(context: ModelDispatcherDefinition.Context) void { + _ = context; //TODO! + } + }.f, + .httpMethod = zzz.HTTP.Method.PATCH, + }).builder(); + }; + } + + /// Make a definition structure to define model deletion operation with the provided policy. + pub fn Delete(policy: ModelDispatcherDefinition.Policy) type { + return struct { + pub const delete = (ModelDispatcherDefinition{ + .policy = policy, + .run = struct { + fn f(context: ModelDispatcherDefinition.Context) void { + _ = context; //TODO! + } + }.f, + .httpMethod = zzz.HTTP.Method.DELETE, + }).builder(); + }; + } + }; +} diff --git a/lib/dispatchers.zig b/lib/dispatchers.zig new file mode 100644 index 0000000..6912248 --- /dev/null +++ b/lib/dispatchers.zig @@ -0,0 +1,127 @@ +const std = @import("std"); +const zzz = @import("zzz"); +const _comptime = @import("utils/comptime.zig"); + +/// Type of a dispatcher run function. +pub fn DispatchFunction(AuthenticationType: type, PayloadType: type) type { + return *const fn(context: DispatcherContext(AuthenticationType, PayloadType)) void; +} + +/// Model dispatcher runtime context structure. +pub fn DispatcherContext(AuthenticationType: type, PayloadType: type) type { + return struct { + /// Payload of the running dispatch. + payload: *const PayloadType, + + /// The authenticated account, if there is one. NULL = no authenticated account. + account: ?*AuthenticationType, + }; +} + +/// Dispatcher builder interface. +pub const DispatcherBuilder = struct { + build: *const fn (comptime modelName: []const u8, comptime dispatchName: []const u8) Dispatcher, +}; + +/// Compile-time model dispatcher definition structure. +pub fn DispatcherDefinition(AuthenticationType: type, PayloadType: type) type { + return struct { + const Self = @This(); + + pub const Context = DispatcherContext(AuthenticationType, PayloadType); + pub const Policy = DispatcherPolicy(AuthenticationType, PayloadType); + pub const PolicyContext = DispatcherPolicyContext(AuthenticationType, PayloadType); + pub const Dispatch = DispatchFunction(AuthenticationType, PayloadType); + + /// Dispatcher call policy. + policy: Policy, + /// Run function of the dispatcher. + run: Dispatch, + + /// HTTP method to use in HTTP API for this dispatcher. + httpMethod: zzz.HTTP.Method = zzz.HTTP.Method.GET, + + /// Initialize a dispatcher builder for the defined dispatcher. + pub fn builder(comptime self: *const Self) DispatcherBuilder { + return DispatcherBuilder{ + .build = struct { + fn f(comptime modelName: []const u8, comptime dispatchName: []const u8) Dispatcher { + // Build the model identifier from the model name. + const modelIdentifier = comptime _comptime.pascalToKebab(modelName); + + return Dispatcher{ + ._interface = .{ + .instance = self, + + .getModelIdentifier = struct { pub fn f(_: *const anyopaque) []const u8 { + return modelIdentifier; + } }.f, + + .getDispatchName = struct { pub fn f(_: *const anyopaque) []const u8 { + return dispatchName; + } }.f, + + .getHttpMethod = struct { pub fn f(opaqueSelf: *const anyopaque) zzz.HTTP.Method { + const impl: *const Self = @ptrCast(@alignCast(opaqueSelf)); + return impl.httpMethod; + } }.f, + }, + }; + } + }.f, + }; + } + }; +} + +/// Compile-time model dispatcher policy definition structure. +pub fn DispatcherPolicy(AuthenticationType: type, PayloadType: type) type { + return union(enum) { + /// Allow / disallow any call to the dispatcher. + any: bool, + /// Allow calls to the dispatcher with any authenticated account. + anyAuthenticated: bool, + /// Allow calls to the dispatcher if the defined function returns true. + func: *const fn(context: DispatcherPolicyContext(AuthenticationType, PayloadType)) bool, //TODO define rules instead of func? + }; +} + +/// Model dispatcher policy runtime context structure. +pub fn DispatcherPolicyContext(AuthenticationType: type, PayloadType: type) type { + return struct { + /// Payload of the running dispatch. + payload: *const PayloadType, + + /// The authenticated account, if there is one. NULL = no authenticated account. + account: ?*AuthenticationType, + }; +} + + +/// Dispatcher interface. +pub const Dispatcher = struct { + const Self = @This(); + + _interface: struct { + instance: *const anyopaque, + + getModelIdentifier: *const fn(instance: *const anyopaque) []const u8, + getDispatchName: *const fn(instance: *const anyopaque) []const u8, + getHttpMethod: *const fn(instance: *const anyopaque) zzz.HTTP.Method, + }, + + /// Get model identifier. + pub fn getModelIdentifier(self: *const Self) []const u8 { + return self._interface.getModelIdentifier(self._interface.instance); + } + + /// Get dispatch name. + pub fn getDispatchName(self: *const Self) []const u8 { + return self._interface.getDispatchName(self._interface.instance); + } + + /// Get HTTP API method. + pub fn getHttpMethod(self: *const Self) zzz.HTTP.Method { + return self._interface.getHttpMethod(self._interface.instance); + } +}; diff --git a/lib/http.zig b/lib/http.zig new file mode 100644 index 0000000..409141e --- /dev/null +++ b/lib/http.zig @@ -0,0 +1,100 @@ +const std = @import("std"); +const zzz = @import("zzz"); +const tardy = @import("tardy"); +const _dispatchers = @import("dispatchers.zig"); +const log = std.log.scoped(.@"pgmql/http"); + +/// HTTP API server structure. +pub const Server = struct { + const Self = @This(); + + const Tardy = tardy.Tardy(.auto); + const Http = zzz.HTTP.Server(.plain); + const Router = Http.Router; + const Route = Http.Route; + const Context = Http.Context; + + allocator: std.mem.Allocator, + dispatchers: []const _dispatchers.Dispatcher, + + /// Host to use. + host: []const u8 = "127.0.0.1", + + /// Port to use. + port: u16 = 8122, + + /// Tardy runtime, if initialized. + ta: ?Tardy = null, + + /// HTTP router, if initialized. + router: ?Router = null, + + /// Initialize a new HTTP API server. + pub fn init(allocator: std.mem.Allocator, dispatchers: []const _dispatchers.Dispatcher) Self { + return Self{ + .allocator = allocator, + .dispatchers = dispatchers, + }; + } + + /// Deinitialize the HTTP API server. + pub fn deinit(self: *Self) void { + if (self.ta) |*_ta| { + _ta.deinit(); + } + + if (self.router) |*_router| { + _router.deinit(); + } + } + + /// Setup Tardy and ZZZ. + fn setup(self: *Self) !void { + self.ta = try Tardy.init(.{ + .allocator = self.allocator, + .threading = .auto, + }); + + self.router = Router.init(self.allocator); + + // Set error JSON response. + //TODO Improve zzz to allow this. + + // Set not found JSON response. + self.router.?.serve_not_found(Route.init().get({}, struct { + fn f(context: *Context, _: void) !void { + // Return a "not found" error as JSON. + try context.respond(.{ + .status = zzz.HTTP.Status.@"Not Found", + .body = "{\"error\": \"not found\"}", + .mime = zzz.HTTP.Mime.JSON, + }); + } + }.f)); + } + + /// Add API endpoints for the given dispatchers. + fn addApi(self: *Self) !void { + for (self.dispatchers) |dispatcher| { + // Add an endpoint of each dispatcher. + _ = dispatcher; + } + } + + /// Start the HTTP API server. + pub fn start(self: *Self) !void { + try self.setup(); + + try self.ta.?.entry( + self, struct { fn f(runtime: *tardy.Runtime, server: *const Server) !void { + // Start a new HTTP server with the provided configuration. + var http = Http.init(runtime.allocator, .{}); + try http.bind(.{ .ip = .{ .host = server.host, .port = server.port } }); + try http.serve(&server.router.?, runtime); + } }.f, + {}, struct { fn f(runtime: *tardy.Runtime, _: void) !void { + try Http.clean(runtime); + } }.f + ); + } +}; diff --git a/lib/model.zig b/lib/model.zig new file mode 100644 index 0000000..3f931cb --- /dev/null +++ b/lib/model.zig @@ -0,0 +1,101 @@ +const std = @import("std"); +const _comptime = @import("utils/comptime.zig"); +const _registry = @import("registry.zig"); +const _dispatchers = @import("dispatchers.zig"); + +/// Compile-time model primary key definition structure. +pub const ModelPrimaryKey = union(enum) { + const Self = @This(); + + /// Simple single-column primary key. + single: []const u8, + /// Composite (multi-columns) primary key. + composite: []const []const u8, //TODO Actually implement composite primary keys. + + /// Get string representation of the model primary key. + pub fn toString(self: Self) []const u8 { + return switch (self) { + .single => |key| key, + .composite => |keys| _comptime.join(", ", keys), + }; + } +}; + +/// Compile-time model definition structure. +pub const Model: type = struct { + const Self = @This(); + + /// Get the structure type of the current model. + pub fn structure(self: Self) type { + return ModelStructure(self); + } + + /// Initialize a new model structure with all values as NULL. + pub fn initStruct(self: Self) self.structure() { + // Initialize a model instance. + var instance: self.structure() = undefined; + inline for (@typeInfo(@TypeOf(instance)).Struct.fields) |field| { + // Initialize all fields to null. + @field(instance, field.name) = null; + } + return instance; + } + + /// Table of the model. + table: []const u8, + /// Primary key of the model. + primaryKey: ModelPrimaryKey, + + /// Model fields and relationships definition. + definition: type, + + /// Get identifier of the model. + pub fn getIdentifier(self: *const Self) []const u8 { + return self.table ++ "(" ++ self.primaryKey.toString() ++ ")"; + } +}; + +/// Get the type of a model structure from its definition. +pub fn ModelStructure(model: Model) type { + // Initialize all fields of the structure. + var structFields: [std.meta.declarations(model.definition).len]std.builtin.Type.StructField = undefined; + + // For each field in the definition, create its corresponding field in the structure. + inline for (std.meta.declarations(model.definition), &structFields) |definitionDelc, *structField| { + var fieldType: type = undefined; + + if (@hasField(@TypeOf(@field(model.definition, definitionDelc.name)), "type")) { + // It's a simple type, getting the corresponding field type to create the structure field. + fieldType = @Type(std.builtin.Type{ + .Optional = .{ .child = @field(model.definition, definitionDelc.name).type } + }); + } else if (@hasField(@TypeOf(@field(model.definition, definitionDelc.name)), "related")) { + // It's a relationship type, getting the corresponding field type. + //const modelType = @field(registry.models, @field(model.definition, definitionDelc.name).related).structure(registry); + fieldType = @Type(std.builtin.Type{ + .Optional = .{ .child = *const anyopaque } //TODO Using anyopaque instead of modelType as a workaround of infinite recursion. + }); + } else { + // Invalid declaration format. + @compileError("invalid declaration \"" ++ definitionDelc.name ++ "\": cannot find its type."); + } + + structField.* = std.builtin.Type.StructField{ + .name = definitionDelc.name, + .type = fieldType, + .default_value = null, + .is_comptime = false, + .alignment = @alignOf(fieldType), + }; + } + + // Return the built structure. + return @Type(std.builtin.Type{ + .Struct = .{ + .layout = std.builtin.Type.ContainerLayout.auto, + .fields = &structFields, + .decls = &[_]std.builtin.Type.Declaration{}, + .is_tuple = false, + }, + }); +} diff --git a/lib/registry.zig b/lib/registry.zig new file mode 100644 index 0000000..b6ea52c --- /dev/null +++ b/lib/registry.zig @@ -0,0 +1,67 @@ +const std = @import("std"); +const zzz = @import("zzz"); +const _comptime = @import("utils/comptime.zig"); +const _api = @import("api.zig"); +const _model = @import("model.zig"); +const _dispatchers = @import("dispatchers.zig"); +const _crud = @import("crud.zig"); + +/// Build models map, to associate model name to their identifier name. +fn BuildModelsMap(comptime models: type) std.StaticStringMap([]const u8) { + // Build key-values array. + var mapKeyValues: [std.meta.declarations(models).len](struct{[]const u8, []const u8}) = undefined; + + // For each model, add its identifier name to the key-values array. + for (&mapKeyValues, std.meta.declarations(models)) |*keyVal, model| { + keyVal.* = .{@field(models, model.name).getIdentifier(), model.name}; + } + + // Initialize a new static string map and return it. + return std.StaticStringMap([]const u8).initComptime(mapKeyValues); +} + +/// Compile-time models registry. +pub fn Registry(comptime models: type, comptime authenticationModelName: []const u8) type { + if (!@hasDecl(models, authenticationModelName)) + @compileError("\"" ++ authenticationModelName ++ "\" not found in models definition structure."); + + return struct { + const SelfRegistry = @This(); + + /// Authentication type, induced from the defined authentication model. + pub const AuthenticationType = @field(Models, authenticationModelName).structure(); + + pub const dispatchers = struct { + /// Create a new dispatcher definition for the current registry. + pub fn Definition(PayloadType: type) type { + return _dispatchers.DispatcherDefinition(AuthenticationType, PayloadType); + } + + /// Default CRUD dispatchers definitions for the given model. + pub fn Crud(model: _model.Model) type { + const ModelDispatcherDefinition = Definition(model.structure()); + return _crud.Crud(ModelDispatcherDefinition); + } + }; + + /// Create a new API with this registry and the provided dispatchers definition. + pub fn Api(comptime dispatchersDefinition: type) type { + return _api.Api(SelfRegistry, dispatchersDefinition); + } + + /// Defined models. + pub const Models = models; + + /// Map models identifiers to models names in the registry. + pub const ModelsMap = BuildModelsMap(models); + + /// Get model name from its provided instance. + pub fn getModelName(comptime model: _model.Model) []const u8 { + if (ModelsMap.get(model.getIdentifier())) |modelName| { + return modelName; + } else { + @compileError("undefined model " ++ model.getIdentifier()); + } + } + }; +} diff --git a/lib/relationships.zig b/lib/relationships.zig new file mode 100644 index 0000000..10b4861 --- /dev/null +++ b/lib/relationships.zig @@ -0,0 +1,89 @@ +const std = @import("std"); + +/// Configure a "one to one" relationship. +pub const OneConfiguration = union(enum) { + /// Direct one-to-one relationship using a local foreign key. + direct: struct { + /// The local foreign key name. + foreignKey: []const u8, + /// Associated model key name. + /// Use the default key name of the associated model. + modelKey: ?[]const u8 = null, + }, + + /// Reverse one-to-one relationship using distant foreign key. + reverse: struct { + /// The distant foreign key name. + /// Use the default key name of the related model. + foreignKey: ?[]const u8 = null, + /// Current model key name. + /// Use the default key name of the current model. + modelKey: ?[]const u8 = null, + }, + + /// Used when performing a one-to-one relationship through an association table. + through: struct { + /// Name of the join table. + table: []const u8, + /// The local foreign key name. + /// Use the default key name of the current model. + foreignKey: ?[]const u8 = null, + /// The foreign key name in the join table. + joinForeignKey: []const u8, + /// The model key name in the join table. + joinModelKey: []const u8, + /// Associated model key name. + /// Use the default key name of the associated model. + modelKey: ?[]const u8 = null, + }, +}; + +/// Type of a related model field. +pub const Model = struct { + /// Name of the related model definition. + related: []const u8, + + /// Configuration of the relationship. + relationship: OneConfiguration, +}; + + + + + +/// Configure a "one to many" or "many to many" relationship. +pub const ManyConfiguration = union(enum) { + /// Direct one-to-many relationship using a distant foreign key. + direct: struct { + /// The distant foreign key name pointing to the current model. + foreignKey: []const u8, + /// Current model key name. + /// Use the default key name of the current model. + modelKey: ?[]const u8 = null, + }, + + /// Used when performing a many-to-many relationship through an association table. + through: struct { + /// Name of the join table. + table: []const u8, + /// The local foreign key name. + /// Use the default key name of the current model. + foreignKey: ?[]const u8 = null, + /// The foreign key name in the join table. + joinForeignKey: []const u8, + /// The model key name in the join table. + joinModelKey: []const u8, + /// Associated model key name. + /// Use the default key name of the associated model. + modelKey: ?[]const u8 = null, + }, +}; + +/// Type of related models collection field. +pub const Models = struct { + /// Name of the related model definition. + related: []const u8, + + /// Configuration of the relationship. + relationship: OneConfiguration, +}; diff --git a/lib/root.zig b/lib/root.zig new file mode 100644 index 0000000..a23eb25 --- /dev/null +++ b/lib/root.zig @@ -0,0 +1,20 @@ +const std = @import("std"); +const _api = @import("api.zig"); +const _registry = @import("registry.zig"); +const _model = @import("model.zig"); + +pub const Api = _api.Api; +pub const Registry = _registry.Registry; + +pub const Model = _model.Model; +pub const ModelPrimaryKey = _model.ModelPrimaryKey; + +pub const dispatchers = @import("dispatchers.zig"); +pub const types = @import("types.zig"); +pub const relationships = @import("relationships.zig"); + +pub const http = @import("http.zig"); + +test { + std.testing.refAllDecls(@This()); +} diff --git a/lib/types.zig b/lib/types.zig new file mode 100644 index 0000000..64fc521 --- /dev/null +++ b/lib/types.zig @@ -0,0 +1,55 @@ +const std = @import("std"); + +/// Structure of a field column. +pub const FieldColumn = struct { + name: ?[]const u8 = null, + type: ?[]const u8 = null, +}; + +/// Type of an integer field. +pub const Int = struct { + column: FieldColumn = .{}, + type: type = i64, +}; + +/// Type of a serial field. +pub const Serial = struct { + column: FieldColumn = .{}, + type: type = i64, +}; + +/// Type of a float field. +pub const Float = struct { + column: FieldColumn = .{}, + type: type = f64, +}; + +/// Type of a decimal (fixed point) field. +pub const Decimal = struct { + column: FieldColumn = .{}, + type: type = []const u8, +}; + +/// Type of a string field. +pub const String = struct { + column: FieldColumn = .{}, + type: type = []const u8, +}; + +/// Type of a datetime field. +pub const DateTime = struct { + column: FieldColumn = .{}, + type: type = []const u8, //TODO maybe something else? +}; + +/// Type of a JSON field. +pub const Json = struct { + column: FieldColumn = .{}, + type: type, +}; + +/// Type of an array field. +pub const Array = struct { + column: FieldColumn = .{}, + type: type, +}; diff --git a/lib/utils/comptime.zig b/lib/utils/comptime.zig new file mode 100644 index 0000000..0b81381 --- /dev/null +++ b/lib/utils/comptime.zig @@ -0,0 +1,69 @@ +const std = @import("std"); + +/// Append an element to the given array at comptime. +pub fn append(array: anytype, element: anytype) @TypeOf(array ++ .{element}) { + return array ++ .{element}; +} + +/// Join strings into one, with the given separator in between. +pub fn join(separator: []const u8, slices: []const[]const u8) []const u8 { + if (slices.len == 0) return ""; + + // Compute total length of the string to make. + const totalLen = total: { + // Compute separator length. + var total = separator.len * (slices.len - 1); + // Add length of all slices. + for (slices) |slice| total += slice.len; + break :total total; + }; + + var buffer: [totalLen]u8 = undefined; + + // Based on std.mem.joinMaybeZ implementation. + @memcpy(buffer[0..slices[0].len], slices[0]); + var buf_index: usize = slices[0].len; + for (slices[1..]) |slice| { + @memcpy(buffer[buf_index .. buf_index + separator.len], separator); + buf_index += separator.len; + @memcpy(buffer[buf_index .. buf_index + slice.len], slice); + buf_index += slice.len; + } + + // Put final buffer in a const variable to allow to use its value at runtime. + const finalBuffer = buffer; + return &finalBuffer; +} + +/// Convert a PascalCased identifier to a kebab-cased identifier. +pub fn pascalToKebab(comptime pascalIdentifier: []const u8) []const u8 { + // Array of all parts of the identifier (separated by uppercased characters). + var parts: []const []const u8 = &[0][]const u8{}; + + // The current identifier part. + var currentPart: []const u8 = &[0]u8{}; + + for (pascalIdentifier) |pascalChar| { + // For each character... + if (std.ascii.isUpper(pascalChar)) { + // ... if it's uppercased, save the current part and initialize a new one. + if (currentPart.len > 0) { + // If the current part length is bigger than 0, save it. + parts = append(parts, currentPart); + } + // Create a new part, with the lowercased character. + currentPart = &[1]u8{std.ascii.toLower(pascalChar)}; + } else { + // ... append the current (not uppercased) character to the current part. + currentPart = append(currentPart, pascalChar); + } + } + + // Append the last current part to parts list. + if (currentPart.len > 0) { + parts = append(parts, currentPart); + } + + // Join all the parts with "-" to create a kebab-cased identifier. + return join("-", parts); +} diff --git a/src/mql.zig b/src/mql.zig new file mode 100644 index 0000000..ebff7ee --- /dev/null +++ b/src/mql.zig @@ -0,0 +1,6 @@ +const std = @import("std"); +const pgmql = @import("pgmql"); + +pub fn main() !void { + std.debug.print("MQL\n", .{}); +} diff --git a/tests/example.zig b/tests/example.zig new file mode 100644 index 0000000..ceaacb0 --- /dev/null +++ b/tests/example.zig @@ -0,0 +1,94 @@ +const std = @import("std"); +const pgmql = @import("pgmql"); + +const _account = @import("models/account.zig"); +const _invoice = @import("models/invoice.zig"); +const _invoice_product = @import("models/invoice_product.zig"); + +pub const registry = pgmql.Registry(struct { + pub const Account: pgmql.Model = _account.AccountModel; + pub const Invoice: pgmql.Model = _invoice.InvoiceModel; + pub const InvoiceProduct: pgmql.Model = _invoice_product.InvoiceProductModel; +}, "Account"); + +const CreateInvoice = registry.dispatchers.Definition(struct {}); + +const api = registry.Api(struct { + pub const Account = registry.dispatchers.Crud(registry.Models.Account).All(.{ .anyAuthenticated = true }); + + pub const Invoice = struct { + pub const create = (CreateInvoice{ + .policy = .{ .anyAuthenticated = true }, + .run = struct { + fn f(context: CreateInvoice.Context) void { + _ = context; //TODO! + } + }.f, + }).builder(); + + pub usingnamespace registry.dispatchers.Crud(registry.Models.Invoice).Read(.{.anyAuthenticated = true}); + pub usingnamespace registry.dispatchers.Crud(registry.Models.Invoice).Update(.{.anyAuthenticated = true}); + pub usingnamespace registry.dispatchers.Crud(registry.Models.Invoice).Delete(.{.anyAuthenticated = true}); + }; + + pub const InvoiceProduct = registry.dispatchers.Crud(registry.Models.InvoiceProduct).All(.{ .any = false }); +}); + +test "example dispatchers" { + const dispatchers = api.getDispatchers(); + + try std.testing.expectEqual(12, dispatchers.len); + + try std.testing.expectEqualStrings("account", dispatchers[0].getModelIdentifier()); + try std.testing.expectEqualStrings("read", dispatchers[0].getDispatchName()); + + try std.testing.expectEqualStrings("account", dispatchers[1].getModelIdentifier()); + try std.testing.expectEqualStrings("create", dispatchers[1].getDispatchName()); + + try std.testing.expectEqualStrings("account", dispatchers[2].getModelIdentifier()); + try std.testing.expectEqualStrings("update", dispatchers[2].getDispatchName()); + + try std.testing.expectEqualStrings("account", dispatchers[3].getModelIdentifier()); + try std.testing.expectEqualStrings("delete", dispatchers[3].getDispatchName()); + + + try std.testing.expectEqualStrings("invoice", dispatchers[4].getModelIdentifier()); + try std.testing.expectEqualStrings("read", dispatchers[4].getDispatchName()); + + try std.testing.expectEqualStrings("invoice", dispatchers[5].getModelIdentifier()); + try std.testing.expectEqualStrings("create", dispatchers[5].getDispatchName()); + + try std.testing.expectEqualStrings("invoice", dispatchers[6].getModelIdentifier()); + try std.testing.expectEqualStrings("update", dispatchers[6].getDispatchName()); + + try std.testing.expectEqualStrings("invoice", dispatchers[7].getModelIdentifier()); + try std.testing.expectEqualStrings("delete", dispatchers[7].getDispatchName()); + + + try std.testing.expectEqualStrings("invoice-product", dispatchers[8].getModelIdentifier()); + try std.testing.expectEqualStrings("read", dispatchers[8].getDispatchName()); + + try std.testing.expectEqualStrings("invoice-product", dispatchers[9].getModelIdentifier()); + try std.testing.expectEqualStrings("create", dispatchers[9].getDispatchName()); + + try std.testing.expectEqualStrings("invoice-product", dispatchers[10].getModelIdentifier()); + try std.testing.expectEqualStrings("update", dispatchers[10].getDispatchName()); + + try std.testing.expectEqualStrings("invoice-product", dispatchers[11].getModelIdentifier()); + try std.testing.expectEqualStrings("delete", dispatchers[11].getDispatchName()); +} + +test "example model HTTP API" { + _ = api; +} + +/// Example HTTP API executable. +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{ .thread_safe = true }){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var httpSrv = pgmql.http.Server.init(allocator, api.getDispatchers()); + defer httpSrv.deinit(); + try httpSrv.start(); +} diff --git a/tests/models/account.zig b/tests/models/account.zig new file mode 100644 index 0000000..c6a0efb --- /dev/null +++ b/tests/models/account.zig @@ -0,0 +1,20 @@ +const std = @import("std"); +const pgmql = @import("pgmql"); +const _example = @import("../example.zig"); + +pub const AccountModel = pgmql.Model{ + .table = "accounts", + .primaryKey = .{ .single = "id" }, + + .definition = struct { + pub const id = pgmql.types.Serial{}; + pub const createdAt = pgmql.types.DateTime{}; + pub const updatedAt = pgmql.types.DateTime{}; + pub const name = pgmql.types.String{}; + pub const role = pgmql.types.String{}; + }, + + //.crudPolicy = _example.builder.policies.crud(.{ .anyAuthenticated = true }), +}; + +pub const Account = AccountModel.structure(); diff --git a/tests/models/invoice.zig b/tests/models/invoice.zig new file mode 100644 index 0000000..710423c --- /dev/null +++ b/tests/models/invoice.zig @@ -0,0 +1,51 @@ +const std = @import("std"); +const pgmql = @import("pgmql"); +const _example = @import("../example.zig"); + +pub const CreateDispatcher = _example.builder.dispatchers.Dispatcher(struct {}); + +pub const InvoiceModel = pgmql.Model{ + .table = "invoices", + .primaryKey = .{ .single = "id" }, + + .definition = struct { + pub const id = pgmql.types.Serial{}; + pub const createdAt = pgmql.types.DateTime{}; + pub const amount = pgmql.types.Decimal{ + .column = .{ + .type = "MONEY", + }, + }; + + pub const products = pgmql.relationships.Models{ + .related = "InvoiceProduct", + .relationship = .{ + .direct = .{ .foreignKey = "invoiceId", }, + }, + }; + }, + + // .crudPolicy = _example.builder.policies.crud(.{ .any = false }), + // + // .dispatchers = struct { + // pub const create = CreateDispatcher{ + // .policy = .{ + // .func = struct { pub fn f(context: CreateDispatcher.PolicyContext) bool { + // if (context.account) |account| { + // return std.mem.eql(u8, "billing", account.role) or std.mem.eql(u8, "admin", account.role); + // } else { + // return false; + // } + // } }.f + // }, + // .run = struct { pub fn f(context: CreateDispatcher.Context) void { + // _ = context; + // + // const invoice = _example.registry.models.Invoice.initStruct(); + // _ = invoice; + // } }.f, + // }; + // }, +}; + +pub const Invoice = InvoiceModel.structure(); diff --git a/tests/models/invoice_product.zig b/tests/models/invoice_product.zig new file mode 100644 index 0000000..6b31a1d --- /dev/null +++ b/tests/models/invoice_product.zig @@ -0,0 +1,30 @@ +const std = @import("std"); +const pgmql = @import("pgmql"); +const _example = @import("../example.zig"); + +pub const InvoiceProductModel = pgmql.Model{ + .table = "invoices_products", + .primaryKey = .{ .single = "id" }, + + .definition = struct { + pub const id = pgmql.types.Serial{}; + pub const invoiceId = pgmql.types.Int{}; + pub const details = pgmql.types.String{}; + pub const amount = pgmql.types.Decimal{ + .column = .{ + .type = "MONEY", + }, + }; + + pub const invoice = pgmql.relationships.Model{ + .related = "Invoice", + .relationship = .{ + .direct = .{ .foreignKey = "invoiceId" }, + }, + }; + }, + + // .crudPolicy = _example.builder.policies.crud(.{ .any = false }), +}; + +pub const InvoiceProduct = InvoiceProductModel.structure(); diff --git a/tests/root.zig b/tests/root.zig new file mode 100644 index 0000000..0981399 --- /dev/null +++ b/tests/root.zig @@ -0,0 +1,3 @@ +comptime { + _ = @import("example.zig"); +}