feat: adding initial uuid support

This commit is contained in:
dswbx
2025-06-06 20:46:01 +02:00
parent abbd372ddf
commit 9a18e354cd
22 changed files with 184 additions and 52 deletions

View File

@@ -233,6 +233,8 @@ export class DataController extends Controller {
const hono = this.create();
const entitiesEnum = this.getEntitiesEnum(this.em);
// @todo: make dynamic based on entity
const idType = s.anyOf([s.number(), s.string()], { coerce: (v) => v as any });
/**
* Function endpoints
@@ -333,7 +335,7 @@ export class DataController extends Controller {
"param",
s.object({
entity: entitiesEnum,
id: s.string(),
id: idType,
}),
),
jsc("query", repoQuery, { skipOpenAPI: true }),
@@ -342,8 +344,9 @@ export class DataController extends Controller {
if (!this.entityExists(entity)) {
return this.notFound(c);
}
console.log("id", id);
const options = c.req.valid("query") as RepoQuery;
const result = await this.em.repository(entity).findId(Number(id), options);
const result = await this.em.repository(entity).findId(id, options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
},
@@ -362,7 +365,7 @@ export class DataController extends Controller {
"param",
s.object({
entity: entitiesEnum,
id: s.string(),
id: idType,
reference: s.string(),
}),
),
@@ -376,7 +379,7 @@ export class DataController extends Controller {
const options = c.req.valid("query") as RepoQuery;
const result = await this.em
.repository(entity)
.findManyByReference(Number(id), reference, options);
.findManyByReference(id, reference, options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 });
},
@@ -485,7 +488,7 @@ export class DataController extends Controller {
tags: ["data"],
}),
permission(DataPermissions.entityUpdate),
jsc("param", s.object({ entity: entitiesEnum, id: s.number() })),
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
jsc("json", s.object({})),
async (c) => {
const { entity, id } = c.req.valid("param");
@@ -493,7 +496,7 @@ export class DataController extends Controller {
return this.notFound(c);
}
const body = (await c.req.json()) as EntityData;
const result = await this.em.mutator(entity).updateOne(Number(id), body);
const result = await this.em.mutator(entity).updateOne(id, body);
return c.json(this.mutatorResult(result));
},
@@ -507,13 +510,13 @@ export class DataController extends Controller {
tags: ["data"],
}),
permission(DataPermissions.entityDelete),
jsc("param", s.object({ entity: entitiesEnum, id: s.number() })),
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
async (c) => {
const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const result = await this.em.mutator(entity).deleteOne(Number(id));
const result = await this.em.mutator(entity).deleteOne(id);
return c.json(this.mutatorResult(result));
},

View File

@@ -31,7 +31,11 @@ export class SqliteConnection extends Connection {
type,
(col: ColumnDefinitionBuilder) => {
if (spec.primary) {
return col.primaryKey().notNull().autoIncrement();
if (spec.type === "integer") {
return col.primaryKey().notNull().autoIncrement();
}
return col.primaryKey().notNull();
}
if (spec.references) {
let relCol = col.references(spec.references);

View File

@@ -1,4 +1,4 @@
import { type Static, StringRecord, objectTransform } from "core/utils";
import { type Static, StringEnum, StringRecord, objectTransform } from "core/utils";
import * as tb from "@sinclair/typebox";
import {
FieldClassMap,
@@ -8,6 +8,7 @@ import {
entityTypes,
} from "data";
import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
import { primaryFieldTypes } from "./fields";
export const FIELDS = {
...FieldClassMap,
@@ -72,6 +73,9 @@ export const indicesSchema = tb.Type.Object(
export const dataConfigSchema = tb.Type.Object(
{
basepath: tb.Type.Optional(tb.Type.String({ default: "/api/data" })),
default_primary_format: tb.Type.Optional(
StringEnum(primaryFieldTypes, { default: "integer" }),
),
entities: tb.Type.Optional(StringRecord(entitiesSchema, { default: {} })),
relations: tb.Type.Optional(StringRecord(tb.Type.Union(relationsSchema), { default: {} })),
indices: tb.Type.Optional(StringRecord(indicesSchema, { default: {} })),

View File

@@ -6,7 +6,13 @@ import {
snakeToPascalWithSpaces,
transformObject,
} from "core/utils";
import { type Field, PrimaryField, type TActionContext, type TRenderContext } from "../fields";
import {
type Field,
PrimaryField,
primaryFieldTypes,
type TActionContext,
type TRenderContext,
} from "../fields";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
@@ -18,6 +24,7 @@ export const entityConfigSchema = Type.Object(
description: Type.Optional(Type.String()),
sort_field: Type.Optional(Type.String({ default: config.data.default_primary_field })),
sort_dir: Type.Optional(StringEnum(["asc", "desc"], { default: "asc" })),
primary_format: Type.Optional(StringEnum(primaryFieldTypes)),
},
{
additionalProperties: false,
@@ -68,7 +75,14 @@ export class Entity<
if (primary_count > 1) {
throw new Error(`Entity "${name}" has more than one primary field`);
}
this.fields = primary_count === 1 ? [] : [new PrimaryField()];
this.fields =
primary_count === 1
? []
: [
new PrimaryField(undefined, {
format: this.config.primary_format,
}),
];
if (fields) {
fields.forEach((field) => this.addField(field));

View File

@@ -143,7 +143,7 @@ export class Mutator<
// if listener returned, take what's returned
const _data = result.returned ? result.params.data : data;
const validatedData = {
let validatedData = {
...entity.getDefaultObject(),
...(await this.getValidatedData(_data, "create")),
};
@@ -159,6 +159,16 @@ export class Mutator<
}
}
// primary
const primary = entity.getPrimaryField();
const primary_value = primary.getNewValue();
if (primary_value) {
validatedData = {
[primary.name]: primary_value,
...validatedData,
};
}
const query = this.conn
.insertInto(entity.name)
.values(validatedData)
@@ -175,7 +185,7 @@ export class Mutator<
async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> {
const entity = this.entity;
if (!Number.isInteger(id)) {
if (!id) {
throw new Error("ID must be provided for update");
}
@@ -212,7 +222,7 @@ export class Mutator<
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
const entity = this.entity;
if (!Number.isInteger(id)) {
if (!id) {
throw new Error("ID must be provided for deletion");
}

View File

@@ -1,13 +1,17 @@
import { config } from "core";
import type { Static } from "core/utils";
import { StringEnum, uuidv7, type Static } from "core/utils";
import { Field, baseFieldConfigSchema } from "./Field";
import * as tbbox from "@sinclair/typebox";
import type { TFieldTSType } from "data/entities/EntityTypescript";
const { Type } = tbbox;
export const primaryFieldTypes = ["integer", "uuid"] as const;
export type TPrimaryFieldFormat = (typeof primaryFieldTypes)[number];
export const primaryFieldConfigSchema = Type.Composite([
Type.Omit(baseFieldConfigSchema, ["required"]),
Type.Object({
format: Type.Optional(StringEnum(primaryFieldTypes, { default: "integer" })),
required: Type.Optional(Type.Literal(false)),
}),
]);
@@ -21,8 +25,8 @@ export class PrimaryField<Required extends true | false = false> extends Field<
> {
override readonly type = "primary";
constructor(name: string = config.data.default_primary_field) {
super(name, { fillable: false, required: false });
constructor(name: string = config.data.default_primary_field, cfg?: PrimaryFieldConfig) {
super(name, { fillable: false, required: false, ...cfg });
}
override isRequired(): boolean {
@@ -30,18 +34,34 @@ export class PrimaryField<Required extends true | false = false> extends Field<
}
protected getSchema() {
return baseFieldConfigSchema;
return primaryFieldConfigSchema;
}
get format() {
return this.config.format ?? "integer";
}
get fieldType() {
return this.format === "integer" ? "integer" : "text";
}
override schema() {
return Object.freeze({
type: "integer",
type: this.fieldType,
name: this.name,
primary: true,
nullable: false,
});
}
getNewValue(): any {
if (this.format === "uuid") {
return uuidv7();
}
return undefined;
}
override async transformPersist(value: any): Promise<number> {
throw new Error("PrimaryField: This function should not be called");
}
@@ -51,11 +71,12 @@ export class PrimaryField<Required extends true | false = false> extends Field<
}
override toType(): TFieldTSType {
const type = this.format === "integer" ? "number" : "string";
return {
...super.toType(),
required: true,
import: [{ package: "kysely", name: "Generated" }],
type: "Generated<number>",
type: `Generated<${type}>`,
};
}
}

View File

@@ -9,6 +9,7 @@ import {
import type { RepoQuery } from "../server/query";
import type { RelationType } from "./relation-types";
import * as tbbox from "@sinclair/typebox";
import type { PrimaryFieldType } from "core";
const { Type } = tbbox;
const directions = ["source", "target"] as const;
@@ -72,7 +73,7 @@ export abstract class EntityRelation<
reference: string,
): KyselyQueryBuilder;
getReferenceQuery(entity: Entity, id: number, reference: string): Partial<RepoQuery> {
getReferenceQuery(entity: Entity, id: PrimaryFieldType, reference: string): Partial<RepoQuery> {
return {};
}

View File

@@ -1,6 +1,6 @@
import { type Static, StringEnum } from "core/utils";
import type { EntityManager } from "../entities";
import { Field, baseFieldConfigSchema } from "../fields";
import { Field, baseFieldConfigSchema, primaryFieldTypes } from "../fields";
import type { EntityRelation } from "./EntityRelation";
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
import * as tbbox from "@sinclair/typebox";
@@ -15,6 +15,7 @@ export const relationFieldConfigSchema = Type.Composite([
reference: Type.String(),
target: Type.String(), // @todo: potentially has to be an instance!
target_field: Type.Optional(Type.String({ default: "id" })),
target_field_type: Type.Optional(StringEnum(["integer", "text"], { default: "integer" })),
on_delete: Type.Optional(StringEnum(CASCADES, { default: "set null" })),
}),
]);
@@ -45,6 +46,7 @@ export class RelationField extends Field<RelationFieldConfig> {
reference: target.reference,
target: target.entity.name,
target_field: target.entity.getPrimaryField().name,
target_field_type: target.entity.getPrimaryField().fieldType,
});
}
@@ -63,7 +65,7 @@ export class RelationField extends Field<RelationFieldConfig> {
override schema() {
return Object.freeze({
...super.schema()!,
type: "integer",
type: this.config.target_field_type ?? "integer",
references: `${this.config.target}.${this.config.target_field}`,
onDelete: this.config.on_delete ?? "set null",
});

View File

@@ -2,6 +2,7 @@ import type { CompiledQuery, TableMetadata } from "kysely";
import type { IndexMetadata, SchemaResponse } from "../connection/Connection";
import type { Entity, EntityManager } from "../entities";
import { PrimaryField } from "../fields";
import { $console } from "core";
type IntrospectedTable = TableMetadata & {
indices: IndexMetadata[];
@@ -332,6 +333,7 @@ export class SchemaManager {
if (config.force) {
try {
$console.info("[SchemaManager]", sql, parameters);
await qb.execute();
} catch (e) {
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);