Skip to content

Commit

Permalink
basic array data type functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
theoephraim committed Jan 21, 2025
1 parent afdd499 commit 2490df7
Show file tree
Hide file tree
Showing 11 changed files with 372 additions and 85 deletions.
6 changes: 6 additions & 0 deletions .changeset/thirty-masks-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@dmno/configraph": patch
"dmno": patch
---

add basic array data type
81 changes: 63 additions & 18 deletions packages/configraph/src/config-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
SchemaError,
} from './errors';

import { ConfigraphBaseTypes, ConfigraphDataType, ConfigraphDataTypeDefinition } from './data-types';
import {
ConfigraphBaseTypes, ConfigraphDataType, ConfigraphDataTypeDefinition, expandDataTypeDefShorthand,
} from './data-types';
import {
ConfigValue, ConfigValueResolver,
ResolverContext,
Expand Down Expand Up @@ -49,6 +51,8 @@ export type PickedNodeDef<NodeMetadata = unknown> = {
transformValue?: (val: any) => any,
};

export const VIRTUAL_CHILD_KEY = '$virtual-child';

export class ConfigraphNode<NodeMetadata = any> {
readonly type: ConfigraphDataType<NodeMetadata>;

Expand All @@ -70,11 +74,16 @@ export class ConfigraphNode<NodeMetadata = any> {
// special handling for object nodes to initialize children
if (this.type.extendsType(ConfigraphBaseTypes.object)) {
_.each(this.type.primitiveType.typeDef._children, (childDef, childKey) => {
this.children[childKey] = new (this.constructor as any)(childKey, childDef, this);
this.children[childKey] = new (this.constructor as any)(childKey, expandDataTypeDefShorthand(childDef), this);
});
} else if (this.type.extendsType(ConfigraphBaseTypes.array)) {
this.children[VIRTUAL_CHILD_KEY] = new (this.constructor as any)(
VIRTUAL_CHILD_KEY,
expandDataTypeDefShorthand(this.type.primitiveType.typeDef._itemSchema),
this,
);
}
// TODO: also need to initialize the `itemType` for array and dictionary
// unless we change how those work altogether...
// TODO: deal with dictionary/map
} catch (err) {
this._schemaErrors.push(err instanceof SchemaError ? err : new SchemaError(err as Error));
debug(err);
Expand Down Expand Up @@ -223,6 +232,12 @@ export class ConfigraphNode<NodeMetadata = any> {
}

async resolve() {
if (this.key === VIRTUAL_CHILD_KEY) {
this.isResolved = true;
this.isFullyResolved = true;
return;
}

// RESET
//! we'll need more logic to reset properly as dependency values are changing, once we incorporate multi-stage async resolution
this.dependencyResolutionError = undefined;
Expand All @@ -249,17 +264,19 @@ export class ConfigraphNode<NodeMetadata = any> {
}


// now deal with resolution
// ~ resolution ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// TODO: need to track dependencies used in coerce/validate/etc

const itemResolverCtx = new ResolverContext((!this.overrides.length && this.valueResolver) || this);
resolverCtxAls.enterWith(itemResolverCtx);

if (this.overrides.length) {
this.debug('using override - marking as resolved');
this.isResolved = true;
} else if (this.valueResolver) {
if (!this.valueResolver.isFullyResolved) {
const wasResolved = this.isResolved;

if (!this.isResolved) {
if (this.overrides.length) {
this.debug('using override - marking as resolved');
this.isResolved = true;
} else if (this.valueResolver) {
this.debug('running node resolver');
await this.valueResolver.resolve(itemResolverCtx);
// some errors mean we are waiting for another node to resolve, so we will retry them
Expand All @@ -269,12 +286,14 @@ export class ConfigraphNode<NodeMetadata = any> {
} else {
this.isResolved = true;
}
} else {
this.debug('no resolver - marking as resolved');
this.isResolved = true;
}
} else {
this.debug('no resolver - marking as resolved');
this.isResolved = true;
}


// ~ coercion ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// apply coercion logic (for example - parse strings into numbers)
// NOTE - currently we trigger this if the resolved value was not undefined
// but we may want to coerce undefined values in some cases as well?
Expand All @@ -297,8 +316,33 @@ export class ConfigraphNode<NodeMetadata = any> {
}
}

// ~ array/dictionary fan-out ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
if (!wasResolved && this.isResolved) {
if (this.type.extendsType(ConfigraphBaseTypes.array)) {
if (_.isArray(this.resolvedRawValue) && this.resolvedRawValue.length) {
const childPlaceholderNode = this.children[VIRTUAL_CHILD_KEY];

// we now initialize the actual child nodes
for (let i = 0; i < this.resolvedRawValue.length; i++) {
// note that the virtual child has already had its type definition "expanded" (in case it was using a shorthand)
this.children[i] ||= new (this.constructor as any)(i, childPlaceholderNode.type.typeDef, this);
}
// not sure if we want to delete the placeholder or what?
// delete this.children[VIRTUAL_CHILD_KEY];
}
}
}
// TODO: dictionary



// ~ complex type roll-up ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

// special handling for objects - roll up child values into parent
if (this.type.extendsType(ConfigraphBaseTypes.object)) {
const isObject = this.type.extendsType(ConfigraphBaseTypes.object)
|| this.type.extendsType(ConfigraphBaseTypes.dictionary);
const isArray = this.type.extendsType(ConfigraphBaseTypes.array);
if (isObject || isArray) {
let waitingForChildCount = 0;
_.each(this.children, (c) => {
if (!c.isFullyResolved) waitingForChildCount++;
Expand All @@ -313,21 +357,22 @@ export class ConfigraphNode<NodeMetadata = any> {
// now roll up the child values into the parent object
let finalParentObjectValue: any;
for (const childKey in this.children) {
if (isArray && childKey === VIRTUAL_CHILD_KEY) continue;
const childNode = this.children[childKey];
if (childNode.resolvedValue !== undefined) {
finalParentObjectValue ||= {};
finalParentObjectValue ||= isArray ? [] : {};
finalParentObjectValue[childKey] = childNode.resolvedValue;
}
}
this.resolvedValue = finalParentObjectValue;
// special handling to maintain empty object vs undefined
if (finalParentObjectValue === undefined) {
if (this.resolvedRawValue && _.isEmpty(this.resolvedRawValue)) {
this.resolvedValue = {};
this.resolvedValue = isArray ? [] : {};
}
}

// now do one more pass to check if we have invalid children, and mark the object as invalid
// now do one more pass to check if we have invalid children, and mark the parent as invalid
if (this.resolvedValue !== undefined && this.resolvedValue !== null) {
const invalidChildCount = _.sumBy(_.values(this.children), (c) => (!c.isValid ? 1 : 0));
if (invalidChildCount) {
Expand All @@ -342,7 +387,7 @@ export class ConfigraphNode<NodeMetadata = any> {
}
}

// run validation logic
// ~ validation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
if (!this.validationErrors) {
const validationResult = this.type.validate(_.cloneDeep(this.resolvedValue), itemResolverCtx);
this.validationErrors = validationResult === true ? [] : validationResult;
Expand Down
132 changes: 96 additions & 36 deletions packages/configraph/src/data-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,7 @@ const ObjectDataType = createConfigraphDataType(
childrenSchema: Record<string, ConfigraphDataTypeDefinitionOrShorthand>,
otherSettings?: {
allowEmpty?: boolean,
parseJson?: boolean,
},
) => ({
typeLabel: 'dmno/object',
Expand All @@ -907,21 +908,23 @@ const ObjectDataType = createConfigraphDataType(
ui: { icon: 'tabler:code-dots' }, // this one has 3 dots inside brackets, vs simple object is only brackets
coerce(val) {
if (_.isPlainObject(val)) return val;
if (!_.isString(val)) {
return new CoercionError('Only strings can be coerced into objects via JSON.parse');
}
try {
const parsed = JSON.parse(val);
if (_.isPlainObject(val)) return parsed;
return new CoercionError('String passed JSON.parse but is not an object');
} catch (err) {
return new CoercionError('String was unable to JSON.parse');
if (_.isString(val) && otherSettings?.parseJson) {
try {
return JSON.parse(val);
} catch (err) {
throw new CoercionError('String was unable to JSON.parse', { err: err as any });
}
}
return val;
},
validate(val) {
// special handling to not allow empty strings (unless explicitly allowed)
if (!_.isPlainObject(val)) {
throw new ValidationError('Value must be a object');
}

// special handling to not allow empty strings (unless explicitly allowed)
if (_.isEmpty(val) && !otherSettings?.allowEmpty) {
return [new ValidationError('If set, object must not be empty')];
throw new ValidationError('If set, object must not be empty');
}
return true;
},
Expand All @@ -938,11 +941,6 @@ const ObjectDataType = createConfigraphDataType(
* @category BaseTypes
*/
export type ArrayDataTypeSettings = {
/**
* The schema definition for each item in the array.
*/
itemSchema?: ConfigraphDataTypeDefinitionOrShorthand;

/**
* The minimum length of the array.
*/
Expand All @@ -957,27 +955,55 @@ export type ArrayDataTypeSettings = {
* The exact length of the array.
*/
isLength?: number;

splitString?: string

castArray?: boolean
allowEmpty?: boolean
};
const ArrayDataType = createConfigraphDataType((settings?: ArrayDataTypeSettings) => ({
typeLabel: 'dmno/array',
extends: PrimitiveBaseType,
injectable: false,
ui: { icon: 'tabler:brackets' }, // square brackets
// TODO: validate checks if it's an array
// helper to coerce csv string into array of strings
}));
const ArrayDataType = createConfigraphDataType(
(itemSchema?: ConfigraphDataTypeDefinitionOrShorthand, settings?: ArrayDataTypeSettings) => ({
typeLabel: 'dmno/array',
extends: PrimitiveBaseType,
injectable: false,
ui: { icon: 'tabler:brackets' }, // square brackets
coerce(val) {
if (_.isArray(val)) return val;
if (_.isString(val) && settings?.splitString) {
return val.split(settings?.splitString);
}
if (settings?.castArray && val !== null && val !== undefined) return [val];
return val;
},
validate(val) {
if (!_.isArray(val)) {
throw new ValidationError('Value is not an array');
}
// special handling to not allow empty arrays (unless explicitly allowed)
if (!settings?.allowEmpty && val.length === 0) {
throw new ValidationError('Array must not be empty');
}
if (settings?.minLength !== undefined && val.length < settings.minLength) {
throw new ValidationError(`Array must contain at least ${settings.minLength} items`);
}
if (settings?.maxLength !== undefined && val.length > settings.maxLength) {
throw new ValidationError(`Array must not contain more than ${settings.maxLength} items`);
}
return true;
},

// special place to store the child schema
// TODO: improve types for this
_itemSchema: itemSchema,
}),
);


/**
* Represents the settings for the DictionaryDataType.
* @category BaseTypes
*/
export type DictionaryDataTypeSettings = {
/**
* The schema definition for each item in the dictionary.
*/
itemSchema?: ConfigraphDataTypeDefinitionOrShorthand;

/**
* The minimum number of items in the dictionary.
*/
Expand All @@ -1002,14 +1028,48 @@ export type DictionaryDataTypeSettings = {
* A description of the keys of the dictionary.
*/
keyDescription?: string;

allowEmpty?: boolean;
parseJson?: boolean
};
const DictionaryDataType = createConfigraphDataType((settings?: DictionaryDataTypeSettings) => ({
typeLabel: 'dmno/dictionary',
extends: PrimitiveBaseType,
injectable: false,
ui: { icon: 'tabler:code-asterisk' }, // curly brackets with an asterisk inside
// TODO: validate checks if it's an object
}));
const DictionaryDataType = createConfigraphDataType(
(itemSchema?: ConfigraphDataTypeDefinitionOrShorthand, settings?: DictionaryDataTypeSettings) => ({
typeLabel: 'dmno/dictionary',
extends: PrimitiveBaseType,
injectable: false,
ui: { icon: 'tabler:code-asterisk' }, // curly brackets with an asterisk inside
coerce(val) {
if (_.isPlainObject(val)) return val;
if (_.isString(val) && settings?.parseJson) {
try {
return JSON.parse(val);
} catch (err) {
throw new CoercionError('Unable to parse string as JSON', { err: err as any });
}
}
return val;
},
validate(val) {
if (!_.isPlainObject(val)) {
throw new ValidationError('Value must be an object');
}
const numItems = _.values(val).length;
if (!numItems && !settings?.allowEmpty) {
throw new ValidationError('Object must not be empty');
}

if (settings?.minItems !== undefined && numItems < settings.minItems) {
throw new ValidationError(`Dictionary must contain at least ${settings.minItems} items`);
}
if (settings?.maxItems !== undefined && numItems > settings.maxItems) {
throw new ValidationError(`Dictionary must not contain more than ${settings.maxItems} items`);
}

return true;
},

}),
);

type PossibleEnumValues = string | number | boolean; // do we need explicitly allow null/undefined?
type ExtendedEnumDescription = {
Expand Down
Loading

0 comments on commit 2490df7

Please # to comment.