Skip to content

Commit

Permalink
feat: implement undefinedAsNull keyword for enum type (#176)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexander-akait committed Jun 7, 2023
1 parent d350b63 commit 95826eb
Show file tree
Hide file tree
Showing 9 changed files with 270 additions and 11 deletions.
8 changes: 8 additions & 0 deletions declarations/keywords/undefinedAsNull.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default addUndefinedAsNullKeyword;
export type Ajv = import("ajv").Ajv;
/**
*
* @param {Ajv} ajv
* @returns {Ajv}
*/
declare function addUndefinedAsNullKeyword(ajv: Ajv): Ajv;
28 changes: 28 additions & 0 deletions declarations/validate.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type Extend = {
formatExclusiveMinimum?: boolean | undefined;
formatExclusiveMaximum?: boolean | undefined;
link?: string | undefined;
undefinedAsNull?: boolean | undefined;
};
export type Schema = (JSONSchema4 | JSONSchema6 | JSONSchema7) & Extend;
export type SchemaUtilErrorObject = ErrorObject & {
Expand All @@ -22,6 +23,33 @@ export type ValidationErrorConfiguration = {
baseDataPath?: string | undefined;
postFormatter?: PostFormatter | undefined;
};
/** @typedef {import("json-schema").JSONSchema4} JSONSchema4 */
/** @typedef {import("json-schema").JSONSchema6} JSONSchema6 */
/** @typedef {import("json-schema").JSONSchema7} JSONSchema7 */
/** @typedef {import("ajv").ErrorObject} ErrorObject */
/**
* @typedef {Object} Extend
* @property {number=} formatMinimum
* @property {number=} formatMaximum
* @property {boolean=} formatExclusiveMinimum
* @property {boolean=} formatExclusiveMaximum
* @property {string=} link
* @property {boolean=} undefinedAsNull
*/
/** @typedef {(JSONSchema4 | JSONSchema6 | JSONSchema7) & Extend} Schema */
/** @typedef {ErrorObject & { children?: Array<ErrorObject>}} SchemaUtilErrorObject */
/**
* @callback PostFormatter
* @param {string} formattedError
* @param {SchemaUtilErrorObject} error
* @returns {string}
*/
/**
* @typedef {Object} ValidationErrorConfiguration
* @property {string=} name
* @property {string=} baseDataPath
* @property {PostFormatter=} postFormatter
*/
/**
* @param {Schema} schema
* @param {Array<object> | object} options
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions src/ValidationError.js
Original file line number Diff line number Diff line change
Expand Up @@ -535,9 +535,17 @@ class ValidationError extends Error {
}

if (schema.enum) {
return /** @type {Array<any>} */ (schema.enum)
.map((item) => JSON.stringify(item))
const enumValues = /** @type {Array<any>} */ (schema.enum)
.map((item) => {
if (item === null && schema.undefinedAsNull) {
return `${JSON.stringify(item)} | undefined`;
}

return JSON.stringify(item);
})
.join(" | ");

return `${enumValues}`;
}

if (typeof schema.const !== "undefined") {
Expand Down
96 changes: 96 additions & 0 deletions src/keywords/undefinedAsNull.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/** @typedef {import("ajv").Ajv} Ajv */

/**
*
* @param {Ajv} ajv
* @param {string} keyword
* @param {any} definition
*/
function addKeyword(ajv, keyword, definition) {
let customRuleCode;

try {
// @ts-ignore
// eslint-disable-next-line global-require
customRuleCode = require("ajv/lib/dotjs/custom");

// @ts-ignore
const { RULES } = ajv;

let ruleGroup;

for (let i = 0; i < RULES.length; i++) {
const rg = RULES[i];

if (typeof rg.type === "undefined") {
ruleGroup = rg;
break;
}
}

const rule = {
keyword,
definition,
custom: true,
code: customRuleCode,
implements: definition.implements,
};
ruleGroup.rules.unshift(rule);
RULES.custom[keyword] = rule;

RULES.keywords[keyword] = true;
RULES.all[keyword] = true;
} catch (e) {
// Nothing, fallback
}
}

/**
*
* @param {Ajv} ajv
* @returns {Ajv}
*/
function addUndefinedAsNullKeyword(ajv) {
// There is workaround for old versions of ajv, where `before` is not implemented
addKeyword(ajv, "undefinedAsNull", {
modifying: true,
/**
* @param {boolean} kwVal
* @param {unknown} data
* @param {any} parentSchema
* @param {string} dataPath
* @param {unknown} parentData
* @param {number | string} parentDataProperty
* @return {boolean}
*/
validate(
kwVal,
data,
parentSchema,
dataPath,
parentData,
parentDataProperty
) {
if (
kwVal &&
parentSchema &&
typeof parentSchema.enum !== "undefined" &&
parentData &&
typeof parentDataProperty === "number"
) {
const idx = /** @type {number} */ (parentDataProperty);
const parentDataRef = /** @type {any[]} */ (parentData);

if (typeof parentDataRef[idx] === "undefined") {
parentDataRef[idx] = null;
}
}

return true;
},
});

return ajv;
}

export default addUndefinedAsNullKeyword;
9 changes: 6 additions & 3 deletions src/validate.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import addAbsolutePathKeyword from "./keywords/absolutePath";
import addUndefinedAsNullKeyword from "./keywords/undefinedAsNull";

import ValidationError from "./ValidationError";

Expand Down Expand Up @@ -27,10 +28,11 @@ const memoize = (fn) => {
};
};


const getAjv = memoize(() => {
// Use CommonJS require for ajv libs so TypeScript consumers aren't locked into esModuleInterop (see #110).
// eslint-disable-next-line global-require
const Ajv = require("ajv");
// eslint-disable-next-line global-require
const ajvKeywords = require("ajv-keywords");

const ajv = new Ajv({
Expand All @@ -46,13 +48,13 @@ const getAjv = memoize(() => {
"patternRequired",
]);

// Custom keywords
// Custom keywords
addAbsolutePathKeyword(ajv);
addUndefinedAsNullKeyword(ajv);

return ajv;
});


/** @typedef {import("json-schema").JSONSchema4} JSONSchema4 */
/** @typedef {import("json-schema").JSONSchema6} JSONSchema6 */
/** @typedef {import("json-schema").JSONSchema7} JSONSchema7 */
Expand All @@ -65,6 +67,7 @@ const getAjv = memoize(() => {
* @property {boolean=} formatExclusiveMinimum
* @property {boolean=} formatExclusiveMaximum
* @property {string=} link
* @property {boolean=} undefinedAsNull
*/

/** @typedef {(JSONSchema4 | JSONSchema6 | JSONSchema7) & Extend} Schema */
Expand Down
42 changes: 36 additions & 6 deletions test/__snapshots__/index.test.js.snap

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions test/fixtures/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3786,6 +3786,42 @@
"emptyString2": {
"maxLength": 0,
"type": "string"
},
"enumKeywordAndUndefined": {
"undefinedAsNull": true,
"enum": [ 0, false, "", null ]
},
"arrayStringAndEnum": {
"description": "References to other configurations to depend on.",
"type": "array",
"items": {
"anyOf": [
{
"undefinedAsNull": true,
"enum": [ 0, false, "", null ]
},
{
"type": "string",
"minLength": 1
}
]
}
},
"arrayStringAndEnumAndNoUndefined": {
"description": "References to other configurations to depend on.",
"type": "array",
"items": {
"anyOf": [
{
"undefinedAsNull": false,
"enum": [ 0, false, "", null ]
},
{
"type": "string",
"minLength": 1
}
]
}
}
}
}
49 changes: 49 additions & 0 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2971,4 +2971,53 @@ describe("Validation", () => {
{},
webpackSchema
);

createSuccessTestCase("enum with undefined", {
// eslint-disable-next-line no-undefined
enumKeywordAndUndefined: undefined,
});

createSuccessTestCase("enum with undefined #2", {
enumKeywordAndUndefined: 0,
});

createSuccessTestCase("array with enum and undefined", {
arrayStringAndEnum: ["a", "b", "c"],
});

createSuccessTestCase("array with enum and undefined #2", {
// eslint-disable-next-line no-undefined
arrayStringAndEnum: [undefined, false, undefined, 0, "test", undefined],
});

createSuccessTestCase("array with enum and undefined #3", {
// eslint-disable-next-line no-undefined
arrayStringAndEnum: [undefined, null, false, 0, ""],
});

createFailedTestCase(
"array with enum and undefined",
{
arrayStringAndEnum: ["foo", "bar", 1],
},
(msg) => expect(msg).toMatchSnapshot()
);

createFailedTestCase(
"array with enum and undefined #2",
{
// eslint-disable-next-line no-undefined
arrayStringAndEnum: ["foo", "bar", undefined, 1],
},
(msg) => expect(msg).toMatchSnapshot()
);

createFailedTestCase(
"array with enum and undefined #3",
{
// eslint-disable-next-line no-undefined
arrayStringAndEnumAndNoUndefined: ["foo", "bar", undefined],
},
(msg) => expect(msg).toMatchSnapshot()
);
});

0 comments on commit 95826eb

Please sign in to comment.