@autobe/agent
Version:
AI backend server code generator
534 lines (487 loc) • 19.8 kB
text/typescript
import {
AutoBeDatabase,
AutoBeInterfaceSchemaPropertyExclude,
AutoBeInterfaceSchemaPropertyRevise,
AutoBeOpenApi,
} from "@autobe/interface";
import { AutoBeOpenApiTypeChecker, StringUtil } from "@autobe/utils";
import typia, { IValidation } from "typia";
import { AutoBeJsonSchemaValidator } from "../utils/AutoBeJsonSchemaValidator";
import { AutoBeInterfaceSchemaProgrammer } from "./AutoBeInterfaceSchemaProgrammer";
export namespace AutoBeInterfaceSchemaPropertyReviseProgrammer {
export const validate = (props: {
// config
path: string;
errors: IValidation.IError[];
unionTypeName: string;
noModelDescription: string;
// database
everyModels: AutoBeDatabase.IModel[];
model: AutoBeDatabase.IModel | null;
// dto
typeName: string;
schema: AutoBeOpenApi.IJsonSchema.IObject;
excludes: AutoBeInterfaceSchemaPropertyExclude[];
revises: AutoBeInterfaceSchemaPropertyRevise[];
}): void => {
// check individual revises
props.revises.forEach((revise, i) => {
validateProperty({
// config
path: `${props.path}.revises[${i}]`,
errors: props.errors,
unionTypeName: props.unionTypeName,
noModelDescription: props.noModelDescription,
// database
everyModels: props.everyModels,
model: props.model,
// dto
typeName: props.typeName,
schema: props.schema,
revise,
originalDtoSchema: props.schema.properties[revise.key],
});
});
// check all properties are revised
for (const key of Object.keys(props.schema.properties))
if (props.revises.find((revise) => revise.key === key) === undefined)
props.errors.push({
path: `${props.path}.revises[]`,
value: undefined,
expected: `${props.unionTypeName} (key: ${JSON.stringify(key)})`,
description: StringUtil.trim`
Property ${JSON.stringify(key)} is defined in the schema, but not revised.
You MUST provide a revise for EVERY property in the object schema.
`,
});
// check that at least one property survives after revisions
if (
props.revises.length > 0 &&
props.revises.every((r) => r.type === "erase")
)
props.errors.push({
path: `${props.path}.revises`,
expected: "At least one non-erase revision to retain a property",
value: props.revises.map((r) => r.type),
description: StringUtil.trim`
All revisions are "erase", which would leave the schema with zero
properties. An object type used as a DTO must have at least one
property — otherwise the downstream Realize stage will fail with
TypeScript compilation errors (TS2339).
Keep at least one property by using "depict", "create", or "update"
instead of "erase" for the essential fields.
Note that, this is not a recommendation, but an instruction you must follow.
`,
});
if (props.model === null) return;
// check all DB schema properties are revised
const actual: Set<string> = new Set();
const expected: string[] =
AutoBeInterfaceSchemaProgrammer.getDatabaseSchemaProperties({
everyModels: props.everyModels,
model: props.model,
}).map((p) => p.key);
for (const e of props.excludes) actual.add(e.databaseSchemaProperty);
for (const r of props.revises)
if (r.databaseSchemaProperty !== null)
actual.add(r.databaseSchemaProperty);
for (const key of expected)
if (actual.has(key) === false)
props.errors.push({
path: `${props.path}.excludes[]`,
value: undefined,
expected: `${typia.reflect.name<AutoBeInterfaceSchemaPropertyExclude>()} (databaseSchemaProperty: ${JSON.stringify(key)}), or ${props.unionTypeName} (databaseSchemaProperty: ${JSON.stringify(key)})`,
description: StringUtil.trim`
Database property ${JSON.stringify(key)} exists in model
"${props.model.name}" but is not addressed in either "excludes"
or "revises".
Every database property must be explicitly accounted for. You have
two options:
1. If this property belongs in the DTO: add a revision in "revises"
with databaseSchemaProperty: ${JSON.stringify(key)}
2. If this property should NOT appear in the DTO: add an entry to
"excludes" with databaseSchemaProperty: ${JSON.stringify(key)}
and a reason explaining why (e.g., "internal field",
"aggregation relation", "handled by separate endpoint")
Do NOT omit database properties. Either map them or exclude them.
`,
});
// check whether excluded are contained in revises
props.revises.forEach((revise, i) => {
if (revise.databaseSchemaProperty === null) return;
const index: number = props.excludes.findIndex(
(e) => e.databaseSchemaProperty === revise.databaseSchemaProperty,
);
if (index === -1) return;
props.errors.push({
path: `${props.path}.revises[${i}].databaseSchemaProperty`,
expected: `not ${JSON.stringify(revise.databaseSchemaProperty)}, or remove ${props.path}.excludes[${index}]`,
value: revise.databaseSchemaProperty,
description: StringUtil.trim`
Database property ${JSON.stringify(revise.databaseSchemaProperty)}
appears in both "revises[${i}]" and "excludes[${index}]".
A database property must be either excluded OR revised, never both.
Choose one of the following actions:
1. If this property belongs in the DTO: remove it from "excludes"
and keep the revision in "revises"
2. If this property should NOT appear in the DTO: remove this
revision from "revises" (or set its databaseSchemaProperty
to null) and keep the "excludes" entry
`,
});
});
};
const validateProperty = (props: {
// config
path: string;
errors: IValidation.IError[];
unionTypeName: string;
noModelDescription: string;
// database
everyModels: AutoBeDatabase.IModel[];
model: AutoBeDatabase.IModel | null;
// dto
typeName: string;
schema: AutoBeOpenApi.IJsonSchema.IObject;
revise: AutoBeInterfaceSchemaPropertyRevise;
originalDtoSchema:
| AutoBeOpenApi.IJsonSchema
| AutoBeOpenApi.IJsonSchemaProperty
| undefined;
}): void => {
// check property key existence
if (
props.revise.type !== "create" &&
props.schema.properties[props.revise.key] === undefined
)
props.errors.push({
path: `${props.path}.key`,
expected: Object.keys(props.schema.properties)
.map((s) => JSON.stringify(s))
.join(" | "),
value: props.revise.key,
description: StringUtil.trim`
Property ${JSON.stringify(props.revise.key)} does not exist in schema.
To ${props.revise.type} a property, it must exist in the object type.
`,
});
// check schema correctness
if (props.revise.type === "create" || props.revise.type === "update")
AutoBeJsonSchemaValidator.validateSchema({
typeName: props.typeName,
schema: props.revise.schema,
operations: [],
path: `${props.path}.schema`,
errors: props.errors,
});
// check database schema property
const dbSchemaProp:
| AutoBeInterfaceSchemaProgrammer.IDatabaseSchemaMember
| undefined = validateDatabaseSchemaProperty(props);
if (dbSchemaProp !== undefined) {
validateNullable({
...props,
property: dbSchemaProp,
});
validateType({
path: props.path,
errors: props.errors,
property: dbSchemaProp,
revise: props.revise,
originalDtoSchema: props.originalDtoSchema,
});
}
};
const validateDatabaseSchemaProperty = (props: {
path: string;
errors: IValidation.IError[];
everyModels: AutoBeDatabase.IModel[];
model: AutoBeDatabase.IModel | null;
revise: AutoBeInterfaceSchemaPropertyRevise;
originalDtoSchema:
| AutoBeOpenApi.IJsonSchema
| AutoBeOpenApi.IJsonSchemaProperty
| undefined;
noModelDescription: string;
}): AutoBeInterfaceSchemaProgrammer.IDatabaseSchemaMember | undefined => {
const value: string | null = props.revise.databaseSchemaProperty;
if (value === null) return undefined;
else if (props.model === null) {
props.errors.push({
path: `${props.path}.databaseSchemaProperty`,
expected: "null",
value,
description: props.noModelDescription,
});
return;
}
const databaseProperties: AutoBeInterfaceSchemaProgrammer.IDatabaseSchemaMember[] =
AutoBeInterfaceSchemaProgrammer.getDatabaseSchemaProperties({
everyModels: [props.model],
model: props.model,
});
const found:
| AutoBeInterfaceSchemaProgrammer.IDatabaseSchemaMember
| undefined = databaseProperties.find((p) => p.key === value);
if (found === undefined)
props.errors.push({
path: `${props.path}.databaseSchemaProperty`,
expected: databaseProperties
.map((p) => JSON.stringify(p.key))
.join(" | "),
value,
description: StringUtil.trim`
You have defined "databaseSchemaProperty" property with value
${JSON.stringify(value)} that does not match any property (column or relation)
in the database schema "${props.model.name}".
Available properties in "${props.model.name}" are:
${databaseProperties.map((dp) => `- ${dp.key}`).join("\n")}
Choose one of the following actions:
1. If you made a typo and a similar property exists above, correct it
2. If this property is computed/aggregated or composed purely by
business logic (not from DB), set the value to null
3. If no similar property exists above, delete this property entirely
from the schema - the property itself should not exist
The database schema is the source of truth. If the column you expected
does not exist, the property design is incorrect. Do not insist on
non-existent columns or keep trying different names hoping one works.
Note that, this is not a recommendation, but an instruction you must follow.
`,
});
return found;
};
const validateNullable = (props: {
path: string;
errors: IValidation.IError[];
originalDtoSchema: AutoBeOpenApi.IJsonSchema | undefined;
property: AutoBeInterfaceSchemaProgrammer.IDatabaseSchemaMember;
revise: AutoBeInterfaceSchemaPropertyRevise;
}): void => {
const nullable: boolean | null =
props.revise.type === "create" || props.revise.type === "update"
? AutoBeOpenApiTypeChecker.isNullable(props.revise.schema)
: props.revise.type === "nullish"
? props.revise.nullable
: props.revise.type === "erase" ||
props.originalDtoSchema === undefined
? null
: AutoBeOpenApiTypeChecker.isNullable(props.originalDtoSchema);
if (nullable === null) return;
else if (props.property.nullable === false || nullable === true) return;
else if (props.revise.type === "create" || props.revise.type === "update")
props.errors.push({
path: `${props.path}.schema`,
expected: JSON.stringify(
{
oneOf: [
...(AutoBeOpenApiTypeChecker.isOneOf(props.revise.schema)
? props.revise.schema.oneOf
: [
{
...props.revise.schema,
...({
description: undefined,
"x-autobe-specification": undefined,
"x-autobe-database-schema-property": undefined,
} satisfies Partial<
Pick<
AutoBeOpenApi.IJsonSchemaProperty,
| "description"
| "x-autobe-specification"
| "x-autobe-database-schema-property"
>
>),
},
]),
{ type: "null" },
],
...{
description: (
props.revise.schema as AutoBeOpenApi.IJsonSchemaProperty
).description,
"x-autobe-specification": (
props.revise.schema as AutoBeOpenApi.IJsonSchemaProperty
)["x-autobe-specification"],
"x-autobe-database-schema-property": (
props.revise.schema as AutoBeOpenApi.IJsonSchemaProperty
)["x-autobe-database-schema-property"],
},
} satisfies AutoBeOpenApi.IJsonSchema.IOneOf,
null,
2,
),
value: props.revise.schema,
description: StringUtil.trim`
The database column "${props.property.key}" is nullable, but your
schema does not allow null. Wrap it with oneOf including
{ type: "null" } as shown in the expected value above.
`,
});
else if (props.revise.type === "nullish")
props.errors.push({
path: `${props.path}.nullable`,
expected: "true",
value: props.revise.nullable,
description: StringUtil.trim`
The database column "${props.property.key}" is nullable, so
"nullable" must be true. You set it to false.
`,
});
else
props.errors.push({
path: props.path,
expected:
"AutoBeInterfaceSchemaPropertyNullish | AutoBeInterfaceSchemaPropertyUpdate",
value: props.revise,
description: StringUtil.trim`
The database column "${props.property.key}" is nullable, but the
current schema does not allow null.
Use "nullish" type with nullable: true to fix nullability only,
or use "update" type with a schema wrapped in oneOf including
{ type: "null" } if you also need to change the schema.
`,
});
};
const validateType = (props: {
path: string;
errors: IValidation.IError[];
property: AutoBeInterfaceSchemaProgrammer.IDatabaseSchemaMember;
revise: AutoBeInterfaceSchemaPropertyRevise;
originalDtoSchema:
| AutoBeOpenApi.IJsonSchema
| AutoBeOpenApi.IJsonSchemaProperty
| undefined;
}): void => {
// skip relations (object/array) — only validate primitive column types
if (props.property.type === null) return;
// extract the effective schema from the revise action
const schema: AutoBeOpenApi.IJsonSchema | null =
props.revise.type === "create" || props.revise.type === "update"
? props.revise.schema
: props.revise.type === "erase" || props.originalDtoSchema === undefined
? null
: props.originalDtoSchema;
if (schema === null) return;
// for oneOf: at least one non-null member must be type-compatible
if (AutoBeOpenApiTypeChecker.isOneOf(schema)) {
const nonNull = schema.oneOf.filter(
(s) => !AutoBeOpenApiTypeChecker.isNull(s),
);
if (nonNull.length === 0) {
// all-null oneOf (e.g., `null | null`)
const expected = describeExpectedSchema(props.property.type);
props.errors.push({
path: `${props.path}.schema`,
expected,
value: schema,
description: StringUtil.trim`
Database column "${props.property.key}" has type "${props.property.type}",
but the DTO property schema is a union of only null types
(e.g., "null | null") which carries no actual type information.
Expected JSON Schema: ${expected}.
Fix: Replace the entire schema with the correct type for this column.
If the column is nullable, wrap it as:
{ oneOf: [${expected}, { type: "null" }] }.
`,
});
} else if (
!nonNull.some(
(s) =>
props.property.type !== null &&
isTypeCompatible(props.property.type, s),
)
) {
// no non-null member matches the DB column type
const expected = describeExpectedSchema(props.property.type);
props.errors.push({
path: `${props.path}.schema`,
expected,
value: schema,
description: StringUtil.trim`
Database column "${props.property.key}" has type "${props.property.type}",
so the JSON Schema must be: ${expected}.
However, the DTO property declares an incompatible type:
${JSON.stringify(schema, null, 2)}.
Fix: Replace the schema with ${expected}.
If the column is nullable, wrap it as:
{ oneOf: [${expected}, { type: "null" }] }.
The database schema is the source of truth — the DTO property type
must be compatible with the underlying column type.
`,
});
}
return;
}
// non-oneOf: direct type check
if (isTypeCompatible(props.property.type, schema)) return;
const expected = describeExpectedSchema(props.property.type);
props.errors.push({
path: `${props.path}.schema`,
expected,
value: schema,
description: StringUtil.trim`
Database column "${props.property.key}" has type "${props.property.type}",
which expects JSON Schema: ${expected}.
However, the DTO property declares an incompatible type:
${JSON.stringify(schema, null, 2)}.
Fix: Change the schema to match the database column type. The database
schema is the source of truth — the DTO property type must be compatible
with the underlying column type.
`,
});
};
/** Check whether a JSON Schema type is compatible with a database column type. */
const isTypeCompatible = (
dbType: AutoBeDatabase.IPlainField["type"],
schema: AutoBeOpenApi.IJsonSchema,
): boolean => {
switch (dbType) {
case "boolean":
return AutoBeOpenApiTypeChecker.isBoolean(schema);
case "int":
return AutoBeOpenApiTypeChecker.isInteger(schema);
case "double":
return AutoBeOpenApiTypeChecker.isNumber(schema);
case "string":
return AutoBeOpenApiTypeChecker.isString(schema);
case "uuid":
return (
AutoBeOpenApiTypeChecker.isString(schema) && schema.format === "uuid"
);
case "uri":
return (
AutoBeOpenApiTypeChecker.isString(schema) &&
(schema.format === "uri" || schema.format === "url")
);
case "datetime":
return (
AutoBeOpenApiTypeChecker.isString(schema) &&
schema.format === "date-time"
);
}
};
/**
* Human-readable description of the expected JSON Schema for a DB column
* type.
*/
const describeExpectedSchema = (
dbType: AutoBeDatabase.IPlainField["type"],
): string => {
switch (dbType) {
case "boolean":
return '{ type: "boolean" }';
case "int":
return '{ type: "integer" }';
case "double":
return '{ type: "number" }';
case "string":
return '{ type: "string" }';
case "uuid":
return '{ type: "string", format: "uuid" }';
case "uri":
return '{ type: "string", format: "uri" }';
case "datetime":
return '{ type: "string", format: "date-time" }';
}
};
}