Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 0 additions & 27 deletions packages/cli/src/cli/dev/dev-server/database.ts

This file was deleted.

58 changes: 58 additions & 0 deletions packages/cli/src/cli/dev/dev-server/db/database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Datastore from "@seald-io/nedb";
import type { Entity } from "@/core/resources/entity/schema.js";
import { type EntityRecord, Validator } from "./validator.js";

export class Database {
private collections: Map<string, Datastore> = new Map();
private schemas: Map<string, Entity> = new Map();
private validator: Validator = new Validator();

load(entities: Entity[]) {
for (const entity of entities) {
this.collections.set(entity.name, new Datastore());
this.schemas.set(entity.name, entity);
}
}

getCollection(name: string): Datastore | undefined {
return this.collections.get(name);
}

getCollectionNames(): string[] {
return Array.from(this.collections.keys());
}

dropAll() {
for (const collection of this.collections.values()) {
collection.remove({}, { multi: true });
}
this.collections.clear();
this.schemas.clear();
}

validate(entityName: string, record: EntityRecord, partial: boolean = false) {
const schema = this.schemas.get(entityName);
if (!schema) {
throw new Error(`Entity "${entityName}" not found`);
}

return this.validator.validate(record, schema, partial);
}

prepareRecord(
entityName: string,
record: EntityRecord,
partial: boolean = false,
) {
const schema = this.schemas.get(entityName);
if (!schema) {
throw new Error(`Entity "${entityName}" not found`);
}

const filteredRecord = this.validator.filterFields(record, schema);
if (partial) {
return filteredRecord;
}
return this.validator.applyDefaults(filteredRecord, schema);
}
}
230 changes: 230 additions & 0 deletions packages/cli/src/cli/dev/dev-server/db/validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import type {
Entity,
PropertyDefinition,
} from "@/core/resources/entity/schema.js";

export type EntityRecord = Record<string, unknown>;

type ValidationError = {
error_type: "ValidationError";
message: string;
request_id: null;
traceback: "";
};

type ValidationResponse =
| {
hasError: false;
}
| {
hasError: true;
error: ValidationError;
};

// https://docs.base44.com/developers/backend/resources/entities/entity-schemas#field-types
const fieldTypes = [
"string",
"integer",
"number",
"boolean",
"array",
"object",
];

export class Validator {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a high level idea, haven't dived into it -
But as far as i understand the entities schema are basically json-schema standard, and i saw this package @cfworker/json-schema (https://github.com/cfworker/cfworker) which we can feed the schema to and then just call validate?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing is that leaders don't really want to validate - this schema is just documentation of intent
And looks we're going with this approach forward.
json-schema supports far more features than what we need to handle.
Therefore not sure if need to introduce anything that will actually use this schema to validate any further than just types.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked into it once more.
It's possible to use json-schema, not sure if makes code simpler though.
I will still need to do recursions in order to make json-schema work with limited set of validations.
And also I will need to filter out errors that are not relevant to our case.

If you want to take a look, here is a prompt that I used to test it out:

Take a look at how I validate using a schema. Basically, I am only interested in the property type and whether it's required or not. I don't validate other constraints like maxLength, pattern, minLength, etc. I do validate recursively, though. If a property is an object or an array, I will go and check its properties/items, but will only validate the same two things: type and whether it's required. So, I'm thinking whether I can use `@cfworker/json-schema` instead for the same thing. This package does much more, but maybe I can silence other errors or something.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should proceed with the current implentation considering the links you added, I just don't love maintaining this validation logic (and issues that may raise) on the CLI side.. but it looks kinda simple for now 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we will need anyway maintain validation logic in some capacity 😞

/**
* Filter out fields that are not supported by the schema
*/
filterFields(record: EntityRecord, entitySchema: Entity): EntityRecord {
const filteredRecord: EntityRecord = {};
for (const [key, value] of Object.entries(record)) {
if (entitySchema.properties[key]) {
filteredRecord[key] = value;
}
}
return filteredRecord;
}

applyDefaults(record: EntityRecord, entitySchema: Entity): EntityRecord {
const result: EntityRecord = {};
for (const [key, property] of Object.entries(entitySchema.properties)) {
if (property.default !== undefined) {
result[key] = property.default;
}
}
return {
...result,
...record,
};
}

/**
* Validates whether record is correctly follow the schema
*/
validate(
record: EntityRecord,
entitySchema: Entity,
partial: boolean = false,
): ValidationResponse {
// Partial validation happening, when user updates existing entity.
// In this case not all data will be passed.
if (!partial) {
const requiredFieldsResponse = this.validateRequiredFields(
record,
entitySchema,
);
if (requiredFieldsResponse.hasError) {
return requiredFieldsResponse;
}
}
const fieldTypesResponse = this.validateFieldTypes(record, entitySchema);
if (fieldTypesResponse.hasError) {
return fieldTypesResponse;
}
return {
hasError: false,
};
}

private createValidationError(message: string): ValidationError {
return {
error_type: "ValidationError",
message,
request_id: null,
traceback: "",
};
}

/**
* Validating field types according to documentation
*/
private validateFieldTypes(
record: EntityRecord,
entitySchema: Entity,
): ValidationResponse {
for (const [key, value] of Object.entries(record)) {
const property = entitySchema.properties[key];
const result = this.validateValue(value, property, key);
if (result.hasError) return result;
}

return {
hasError: false,
};
}

private validateValue(
value: unknown,
property: PropertyDefinition | undefined,
fieldPath: string,
): ValidationResponse {
// Silently ignore fields not defined in the schema.
// The expectation is that `filterFields()` will run before.
if (!property) {
return { hasError: false };
}

const propertyType = property.type;
if (!fieldTypes.includes(propertyType)) {
return {
hasError: true,
error: this.createValidationError(
`Error in field ${fieldPath}: Unsupported field type ${propertyType}`,
),
};
}

switch (propertyType) {
case "array":
if (!Array.isArray(value)) {
return {
hasError: true,
error: this.createValidationError(
`Error in field ${fieldPath}: Input should be a valid array`,
),
};
}
if (property.items) {
for (let i = 0; i < value.length; i++) {
const itemResult = this.validateValue(
value[i],
property.items,
`${fieldPath}[${i}]`,
);
if (itemResult.hasError) return itemResult;
}
}
break;
case "object":
if (
typeof value !== "object" ||
value === null ||
Array.isArray(value)
) {
return {
hasError: true,
error: this.createValidationError(
`Error in field ${fieldPath}: Input should be a valid object`,
),
};
}
if (property.properties) {
for (const [subKey, subValue] of Object.entries(
value as Record<string, unknown>,
)) {
if (property.properties[subKey]) {
const subResult = this.validateValue(
subValue,
property.properties[subKey],
`${fieldPath}.${subKey}`,
);
if (subResult.hasError) return subResult;
}
}
}
break;
case "integer":
if (!Number.isInteger(value)) {
return {
hasError: true,
error: this.createValidationError(
`Error in field ${fieldPath}: Input should be a valid integer`,
),
};
}
break;
default:
if (typeof value !== propertyType) {
return {
hasError: true,
error: this.createValidationError(
`Error in field ${fieldPath}: Input should be a valid ${propertyType}`,
),
};
}
}

return { hasError: false };
}

private validateRequiredFields(
record: EntityRecord,
entitySchema: Entity,
): ValidationResponse {
if (entitySchema.required && entitySchema.required.length > 0) {
for (const required of entitySchema.required) {
if (record[required] == null) {
return {
hasError: true,
error: this.createValidationError(
`Error in field ${required}: Field required`,
),
};
}
}
}
return {
hasError: false,
};
}
}
2 changes: 1 addition & 1 deletion packages/cli/src/cli/dev/dev-server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { createDevLogger } from "@/cli/dev/createDevLogger.js";
import { FunctionManager } from "@/cli/dev/dev-server/function-manager.js";
import { createFunctionRouter } from "@/cli/dev/dev-server/routes/functions.js";
import type { ProjectData } from "@/core/project/types.js";
import { Database } from "./database.js";
import { Database } from "./db/database.js";
import {
type BroadcastEntityEvent,
broadcastEntityEvent,
Expand Down
Loading
Loading