m1212e / prismabox

typebox schema generator for prisma
MIT License
24 stars 6 forks source link

Fields that have default value should set optional by default ? #21

Open fortezhuo opened 2 days ago

fortezhuo commented 2 days ago

Hi,

I want to suggest how about if fields that have default value | isUpdatedAt should be flagged as optional instead hide them by return undefined ?

model Mail {
  id        String    @id @default(nanoid())
  name      String    @unique
  status    String    @default("Active")
  template  String?

Field status we don't need to fill it, database will auto set "Active" if the field is null. So we don't need to use @prisma.hidden / @prisma.input.hidden

m1212e commented 1 day ago

So e.g. for the model

model Email {
  email  String @id
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
  userId String

  validated         Boolean @default(false)
  validationToken   Token?  @relation(fields: [validationTokenId], references: [id])
  validationTokenId String?

  @@unique([userId, email])
}

which produces this model currently:

export const EmailPlainInputCreate = Type.Object(
  { validated: Type.Boolean({ additionalProperties: false }) },
  { additionalProperties: false },
);

you would expect this instead:

export const EmailPlainInputCreate = Type.Object(
  { validated: Type.Optional(Type.Boolean({ additionalProperties: false })) },
  { additionalProperties: false }
);

if I understand this correctly?

m1212e commented 1 day ago

Please see if 1.1.13 is what you need!

fortezhuo commented 1 day ago

CMIIW, I have tried tag 1.1.13

with model

model User {
  authors   String[]  @default(["*"]) @map("_authors")
  readers   String[]  @default(["*"]) @map("_readers")

  @@map("user")
}

the InputCreate and InputUpdate's schemas have Type.Optional but the model still same.

export const UserPlain = Type.Object(
  {
    authors: Type.Array(Type.String({ additionalProperties: true })), // still same not be optional
    readers: Type.Array(Type.String({ additionalProperties: true })), // still same not be optional
  },
  { additionalProperties: true },
);

And here my previous patch in my local computer, please take a look

import type { DMMF } from "@prisma/generator-helper";
import { extractAnnotations } from "../annotations/annotations";
import { generateTypeboxOptions } from "../annotations/options";
import { getConfig } from "../config";
import type { ProcessedModel } from "../model";
import { makeEnum, processedEnums } from "./enum";
import {
  type PrimitivePrismaFieldType,
  isPrimitivePrismaFieldType,
  stringifyPrimitiveType,
} from "./primitiveField";
import { wrapWithArray } from "./wrappers/array";
import { wrapWithNullable } from "./wrappers/nullable";
import { wrapWithOptional } from "./wrappers/optional";

export const processedPlain: ProcessedModel[] = [];

export function processPlain(models: DMMF.Model[] | Readonly<DMMF.Model[]>) {
  for (const m of models) {
    const o = stringifyPlain(m);
    if (o) {
      processedPlain.push({ name: m.name, stringRepresentation: o });
    }
  }
  Object.freeze(processedPlain);
}

type StringifyPlainOption = {
  isInputModelCreate?: boolean;
  isInputModelUpdate?: boolean;
  isInputSelect?: boolean;
  isInputOrderBy?: boolean;
};

export function stringifyPlain(data: DMMF.Model, opt?: StringifyPlainOption) {
  const annotations = extractAnnotations(data.documentation);

  const stringifyBoolean = stringifyPrimitiveType({
    fieldType: "Boolean",
    options: generateTypeboxOptions({ input: annotations }),
  });
  const stringifyOrderBy = makeEnum(["asc", "desc"]);

  if (
    annotations.isHidden ||
    ((opt?.isInputModelCreate || opt?.isInputModelUpdate) &&
      annotations.isHiddenInput) ||
    (opt?.isInputModelCreate && annotations.isHiddenInputCreate) ||
    (opt?.isInputModelUpdate && annotations.isHiddenInputUpdate)
  )
    return undefined;

  const fields = data.fields
    .map((field) => {
      const annotations = extractAnnotations(field.documentation);
      if (
        annotations.isHidden ||
        ((opt?.isInputModelCreate || opt?.isInputModelUpdate) &&
          annotations.isHiddenInput) ||
        (opt?.isInputModelCreate && annotations.isHiddenInputCreate) ||
        (opt?.isInputModelUpdate && annotations.isHiddenInputUpdate)
      )
        return undefined;

      // ===============================
      // INPUT MODEL FILTERS
      // ===============================
      // if we generate an input model we want to omit certain fields

      if (
        getConfig().ignoreIdOnInputModel &&
        (opt?.isInputModelCreate || opt?.isInputModelUpdate) &&
        field.isId
      )
        return undefined;
      if (
        getConfig().ignoreCreatedAtOnInputModel &&
        (opt?.isInputModelCreate || opt?.isInputModelUpdate) &&
        field.name === "createdAt" &&
        field.hasDefaultValue
      )
        return undefined;
      if (
        getConfig().ignoreUpdatedAtOnInputModel &&
        (opt?.isInputModelCreate || opt?.isInputModelUpdate) &&
        field.isUpdatedAt
      )
        return undefined;

      if (
        (opt?.isInputModelCreate || opt?.isInputModelUpdate) &&
        (field.name.toLowerCase().endsWith("id") ||
          field.name.toLowerCase().endsWith("foreign") ||
          field.name.toLowerCase().endsWith("foreignkey"))
      ) {
        return undefined;
      }

      // ===============================
      // INPUT MODEL FILTERS END
      // ===============================

      let stringifiedType = "";

      if (opt?.isInputSelect) {
        if (isPrimitivePrismaFieldType(field.type)) {
          stringifiedType = wrapWithOptional(stringifyBoolean);
        } else {
          return undefined;
        }
      } else if (opt?.isInputOrderBy) {
        stringifiedType = wrapWithOptional(stringifyOrderBy);
      } else {
        let isWrapped: boolean = false;
        if (isPrimitivePrismaFieldType(field.type)) {
          stringifiedType = stringifyPrimitiveType({
            fieldType: field.type as PrimitivePrismaFieldType,
            options: generateTypeboxOptions({ input: annotations }),
          });
        } else if (processedEnums.find((e) => e.name === field.type)) {
          // biome-ignore lint/style/noNonNullAssertion: we checked this manually
          stringifiedType = processedEnums.find(
            (e) => e.name === field.type
          )!.stringRepresentation;
        } else {
          return undefined;
        }

        if (field.isList) {
          stringifiedType = wrapWithArray(stringifiedType);
        }

        if (!field.isRequired) {
          isWrapped = true;
          stringifiedType = wrapWithNullable(stringifiedType);
          if (opt?.isInputModelCreate) {
            isWrapped = true;
            stringifiedType = wrapWithOptional(stringifiedType);
          }
        }

        if (opt?.isInputModelUpdate) {
          isWrapped = true;
          stringifiedType = wrapWithOptional(stringifiedType);
        }

        if (!isWrapped) {
          if (field.hasDefaultValue || field.isUpdatedAt || !field.isRequired) {
            stringifiedType = wrapWithOptional(stringifiedType);
          }
        }
      }

      return `${field.name}: ${stringifiedType}`;
    })
    .filter((x) => x) as string[];

  return `${getConfig().typeboxImportVariableName}.Object({${[
    ...fields,
    opt?.isInputSelect
      ? [`_count: ${wrapWithOptional(stringifyBoolean)}`]
      : !opt?.isInputOrderBy &&
          !(opt?.isInputModelCreate || opt?.isInputModelUpdate)
        ? (getConfig().additionalFieldsPlain ?? [])
        : [],
  ].join(",")}},${generateTypeboxOptions({ input: annotations })})\n`;
}
m1212e commented 21 hours ago

Well, since the values only have default values but are not optional in the model the plain model does not reflect that. This is the case because when you query the db for the entity the value will always be there because it is either set manually or via the default value but the DB does not allow the field to be not set. Therefore the model does not allow this either.

When it comes to the input models which are specifically designed to validate user input this is no longer the case since we optionally can provide inputs for the default annotated fields but don't need to. I hope this clarifies your questions?

fortezhuo commented 13 hours ago

I got the point, we have different needs. Since I need to validate user input on front end (I use react hook form), the input models with { connect } by default, don't fit my needs, and I use model composite schema for frontend user validate for the need.

And I also have another opinionated needs, so I think I will fork this repo for fit my needs.

With this, I ask politely for obtain your permission to fork/custom and re-publish in my org 🙏🙏🙏

m1212e commented 1 hour ago

You are free to do so, please see the very permissive license: https://github.com/m1212e/prismabox?tab=MIT-1-ov-file#readme

I see your point but I think this project is more suited to validating data objects coming in and going out from APIs. One could argue about adding a "Form" model to the generated outputs but I think this is out of scope for this project. I'd recommend you use the typebox util types like Omit and Pick to tailor the models to your needs. This way you could adjust the output models to your requirements which would require some manual adjustments to each model.

Say you want to make the readers field optional:

const AdjustedUser = Type.Composite([
    Type.Omit(UserPlain, ["readers"]),
    Type.Pick(Type.Partial(UserPlain), ["readers"])
])

which you could easily put in a generic function which just accepts a key(array) to "optionalize" certain fields.