Skip to content

Commit

Permalink
H-2958: Support anyOf in type system (#5232)
Browse files Browse the repository at this point in the history
Co-authored-by: Ciaran Morinan <c@hash.ai>
  • Loading branch information
TimDiekmann and CiaranMn authored Sep 29, 2024
1 parent 77e722e commit 301d8aa
Show file tree
Hide file tree
Showing 19 changed files with 461 additions and 311 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@ const guessDataTypeFromValue = (
) => {
const editorType = guessEditorTypeFromValue(value, expectedTypes);

const expectedType = expectedTypes.find((type) => type.type === editorType);
const expectedType = expectedTypes.find((type) =>
"type" in type
? type.type === editorType
: /**
* @todo H-3374 support anyOf in expected types. also don't need to guess the value any more, use dataTypeId from property metadata
*/
type.anyOf.some((subType) => subType.type === editorType),
);
if (!expectedType) {
throw new Error(
`Could not find guessed editor type ${editorType} among expected types ${expectedTypes
Expand Down Expand Up @@ -55,7 +62,14 @@ export const renderValueCell: CustomRenderer<ValueCell> = {
const left = rect.x + getCellHorizontalPadding();

const editorType = guessEditorTypeFromValue(value, expectedTypes);
const relevantType = expectedTypes.find((type) => type.type === editorType);
const relevantType = expectedTypes.find((type) =>
"type" in type
? type.type === editorType
: /**
* @todo H-3374 support anyOf in expected types. also don't need to guess the value any more, use dataTypeId from property metadata
*/
type.anyOf.some((subType) => subType.type === editorType),
);

const editorSpec = getEditorSpecs(editorType, relevantType);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,15 @@ export const SortableRow = ({

const editorType =
overriddenEditorType ?? guessEditorTypeFromValue(value, expectedTypes);
const expectedType = expectedTypes.find((type) => type.type === editorType);

const expectedType = expectedTypes.find((type) =>
"type" in type
? type.type === editorType
: /**
* @todo H-3374 support multiple expected data types
*/
type.anyOf.some((subType) => subType.type === editorType),
);

const editorSpec = getEditorSpecs(editorType, expectedType);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,15 @@ export const SingleValueEditor: ValueCellEditorComponent = (props) => {
);
}

const expectedType = expectedTypes.find((type) => type.type === editorType);
const expectedType = expectedTypes.find((type) =>
"type" in type
? type.type === editorType
: /**
* @todo H-3374 support anyOf in expected types. also don't need to guess the value any more, use dataTypeId from property metadata
*/
type.anyOf.some((subType) => subType.type === editorType),
);

if (!expectedType) {
throw new Error(
`Could not find guessed editor type ${editorType} among expected types ${expectedTypes
Expand Down
Original file line number Diff line number Diff line change
@@ -1,46 +1,54 @@
import type { DataType } from "@blockprotocol/type-system/slim";
import type { DataTypeWithMetadata } from "@local/hash-graph-types/ontology";
import isPlainObject from "lodash/isPlainObject";

import type { EditorType } from "./types";

const isEmptyArray = (value: unknown) => Array.isArray(value) && !value.length;

const isValidTypeForSchemas = (
type: "string" | "boolean" | "number" | "object" | "null",
expectedTypes: DataType[],
) =>
expectedTypes.some((dataType) =>
"type" in dataType
? dataType.type === type
: dataType.anyOf.some((subType) => subType.type === type),
);

/**
* @todo H-3374 we don't need to guess the type anymore, because the exact dataTypeId will be in the entity's metadata
*/
export const guessEditorTypeFromValue = (
value: unknown,
expectedTypes: DataTypeWithMetadata["schema"][],
): EditorType => {
if (
typeof value === "string" &&
expectedTypes.some((dataType) => dataType.type === "string")
isValidTypeForSchemas("string", expectedTypes)
) {
return "string";
}

if (
typeof value === "boolean" &&
expectedTypes.some((dataType) => dataType.type === "boolean")
isValidTypeForSchemas("boolean", expectedTypes)
) {
return "boolean";
}

if (
typeof value === "number" &&
expectedTypes.some((dataType) => dataType.type === "number")
isValidTypeForSchemas("number", expectedTypes)
) {
return "number";
}

if (
isPlainObject(value) &&
expectedTypes.some((dataType) => dataType.type === "object")
) {
if (isPlainObject(value) && isValidTypeForSchemas("object", expectedTypes)) {
return "object";
}

if (
value === null &&
expectedTypes.some((dataType) => dataType.type === "null")
) {
if (value === null && isValidTypeForSchemas("null", expectedTypes)) {
return "null";
}

Expand All @@ -61,11 +69,25 @@ export const guessEditorTypeFromExpectedType = (
return "emptyList";
}

if (dataType.type === "array") {
let type: "string" | "number" | "boolean" | "object" | "null" | "array";

if ("anyOf" in dataType) {
/**
* @todo H-3374 support multiple expected data types
*/
type = dataType.anyOf[0].type;
} else {
type = dataType.type;
}

if (type === "array") {
/**
* @todo H-3374 support array and tuple data types
*/
throw new Error("Array data types are not yet handled.");
}

return dataType.type;
return type;
};

export const isBlankStringOrNullish = (value: unknown) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,13 @@ export const useCreateGetCellContent = (
}

if (shouldShowChangeTypeCell) {
const currentType = row.expectedTypes.find(
(opt) => opt.type === guessedType,
const currentType = row.expectedTypes.find((opt) =>
"type" in opt
? opt.type === guessedType
: /**
* @todo H-3374 support anyOf in expected types. also don't need to guess the value any more, use dataTypeId from property metadata
*/
opt.anyOf.some((subType) => subType.type === guessedType),
);
if (!currentType) {
throw new Error(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ use error_stack::{Report, ReportSink, ResultExt};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;

use crate::schema::{ConstraintError, ValueLabel, data_type::constraint::SimpleTypedValueSchema};
use crate::schema::{ConstraintError, SingleValueSchema};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AnyOfConstraints {
#[cfg_attr(
target_arch = "wasm32",
tsify(type = "[SimpleTypedValueSchema, ...SimpleTypedValueSchema[]]")
tsify(type = "[SingleValueSchema, ...SingleValueSchema[]]")
)]
pub any_of: Vec<SimpleTypedValueSchema>,
pub any_of: Vec<SingleValueSchema>,
}

impl AnyOfConstraints {
Expand All @@ -26,24 +26,13 @@ impl AnyOfConstraints {
pub fn validate_value(&self, value: &JsonValue) -> Result<(), Report<ConstraintError>> {
let mut status = ReportSink::<ConstraintError>::new();
for schema in &self.any_of {
if let Err(error) = schema.constraints.validate_value(value) {
status.capture(error);
} else {
if status
.attempt(schema.constraints.validate_value(value))
.is_some()
{
return Ok(());
}
}
status.finish().change_context(ConstraintError::AnyOf)
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AnyOfSchema {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "ValueLabel::is_empty")]
pub label: ValueLabel,
#[serde(flatten)]
pub constraints: AnyOfConstraints,
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use thiserror::Error;

use crate::schema::{ConstraintError, data_type::constraint::SimpleValueSchema};
use crate::schema::{
ConstraintError, JsonSchemaValueType, NumberSchema, StringSchema, ValueLabel,
data_type::constraint::{
boolean::validate_boolean_value, number::validate_number_value,
string::validate_string_value,
},
};

#[derive(Debug, Error)]
pub enum ArrayValidationError {
Expand All @@ -31,6 +37,58 @@ pub enum ArrayTypeTag {
Array,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ArrayItemConstraints {
Boolean,
Number(NumberSchema),
String(StringSchema),
}

impl ArrayItemConstraints {
pub fn validate_value(&self, value: &JsonValue) -> Result<(), Report<ConstraintError>> {
match self {
Self::Boolean => validate_boolean_value(value),
Self::Number(schema) => validate_number_value(value, schema),
Self::String(schema) => validate_string_value(value, schema),
}
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ArrayItemsSchema {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "ValueLabel::is_empty")]
pub label: ValueLabel,
#[serde(flatten)]
pub constraints: ArrayItemConstraints,
}

#[cfg(target_arch = "wasm32")]
#[expect(
dead_code,
reason = "Used to export type to TypeScript to prevent Tsify generating interfaces"
)]
mod wasm {
use super::*;

#[derive(tsify::Tsify)]
#[serde(untagged)]
enum ArrayItemsSchema {
Schema {
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(default)]
label: ValueLabel,
#[serde(flatten)]
constraints: ArrayItemConstraints,
},
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))]
#[serde(untagged, rename_all = "camelCase")]
Expand Down Expand Up @@ -76,7 +134,7 @@ impl ArraySchema {
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ArrayConstraints {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub items: Option<SimpleValueSchema>,
pub items: Option<ArrayItemsSchema>,
}

impl ArrayConstraints {
Expand All @@ -97,7 +155,7 @@ impl ArrayConstraints {
status.attempt(
values
.iter()
.map(|value| items.validate_value(value))
.map(|value| items.constraints.validate_value(value))
.try_collect_reports::<Vec<()>>()
.change_context(ArrayValidationError::Items),
);
Expand All @@ -116,9 +174,9 @@ pub struct TupleConstraints {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[cfg_attr(
target_arch = "wasm32",
tsify(type = "[SimpleValueSchema, ...SimpleValueSchema[]]")
tsify(type = "[ArrayItemsSchema, ...ArrayItemsSchema[]]")
)]
pub prefix_items: Vec<SimpleValueSchema>,
pub prefix_items: Vec<ArrayItemsSchema>,
}

impl TupleConstraints {
Expand Down Expand Up @@ -159,7 +217,7 @@ impl TupleConstraints {
self.prefix_items
.iter()
.zip(values)
.map(|(schema, value)| schema.validate_value(value))
.map(|(schema, value)| schema.constraints.validate_value(value))
.try_collect_reports::<Vec<()>>()
.change_context(ArrayValidationError::PrefixItems),
);
Expand All @@ -168,6 +226,20 @@ impl TupleConstraints {
}
}

pub(crate) fn validate_array_value(
value: &JsonValue,
schema: &ArraySchema,
) -> Result<(), Report<ConstraintError>> {
if let JsonValue::Array(string) = value {
schema.validate_value(string)
} else {
bail!(ConstraintError::InvalidType {
actual: JsonSchemaValueType::from(value),
expected: JsonSchemaValueType::Array,
});
}
}

#[cfg(test)]
mod tests {
use serde_json::{from_value, json};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
use error_stack::{Report, bail};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;

use crate::schema::{ConstraintError, JsonSchemaValueType};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))]
#[serde(rename_all = "camelCase")]
pub enum BooleanTypeTag {
Boolean,
}

pub(crate) fn validate_boolean_value(value: &JsonValue) -> Result<(), Report<ConstraintError>> {
if value.is_boolean() {
Ok(())
} else {
bail!(ConstraintError::InvalidType {
actual: JsonSchemaValueType::from(value),
expected: JsonSchemaValueType::Boolean,
})
}
}
Loading

0 comments on commit 301d8aa

Please sign in to comment.