Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement undefinedAsNull keyword for enum type #176

Merged
merged 3 commits into from
Jun 7, 2023
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
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()
);
});