vitalics / ajv-ts

First-class ajv typescript JSON-schema builder inspired from Zod
https://www.npmjs.com/package/ajv-ts
MIT License
43 stars 3 forks source link

Bug: enum nullable don't work & optional #61

Closed PandaWorker closed 1 month ago

PandaWorker commented 2 months ago

Ajv supports json-schema draft-7 (2020-12), and I think it would be better to rewrite it, since it is more convenient and flexible And I think that the methods should not modify the scheme, but should return a new scheme, as is done in other validators

import { s } from 'ajv-ts';
import { assertEquals } from 'https://deno.land/std@0.219.0/assert/mod.ts';

Deno.test('from JSON', async t => {
    const sexJsonSchema = {
        type: 'string',
        enum: ['male', 'female'],
        title: 'Sex',
    };

    const nullableSexJsonSchema = {
        anyOf: [sexJsonSchema, { type: 'null' }],
    };

    const sex = s.fromJSON(sexJsonSchema);
    const nullableSex = s.fromJSON(nullableSexJsonSchema);

    const optionalNullableObj = s.fromJSON({
        type: 'object',
        properties: {
            sex: nullableSexJsonSchema,
        },
    });

    const requiredNullableObj = s.fromJSON({
        type: 'object',
        properties: {
            sex: nullableSexJsonSchema,
        },
        required: ['sex'],
    });

    await t.step('enum', () => {
        assertEquals(sex.parse('male'), 'male');
        assertEquals(sex.parse('female'), 'female');
        assertEquals(sex.validate(null), false);
        assertEquals(sex.validate(undefined), false);
    });

    await t.step('nullable enum', () => {
        assertEquals(nullableSex.parse('male'), 'male');
        assertEquals(nullableSex.parse('female'), 'female');
        assertEquals(nullableSex.validate(null), true);
        assertEquals(nullableSex.validate(undefined), false);
    });

    await t.step('optionalNullableObj', () => {
        assertEquals(optionalNullableObj.validate({}), true);
        assertEquals(optionalNullableObj.validate({ sex: null }), true);
        assertEquals(optionalNullableObj.validate({ sex: 'male' }), true);
    });

    await t.step('requiredNullableObj', () => {
        assertEquals(requiredNullableObj.validate({}), false);
        assertEquals(requiredNullableObj.validate({ sex: null }), true);
        assertEquals(requiredNullableObj.validate({ sex: 'male' }), true);
    });
});

Deno.test('from ajv-ts', async t => {
    const sex = s.enum(['male', 'female']);
    const nullableSex = s.enum(['male', 'female']).nullable();

    const optionalNullableObj = s.object({
        sex: s.enum(['male', 'female']).nullable().optional(),
    });

    await t.step('enum', () => {
        assertEquals(sex.parse('male'), 'male');
        assertEquals(sex.parse('female'), 'female');
        assertEquals(sex.validate(null), false);
        assertEquals(sex.validate(undefined), false);
    });

    // fail
    await t.step('nullable enum', () => {
        assertEquals(nullableSex.parse('male'), 'male');
        assertEquals(nullableSex.parse('female'), 'female');
        assertEquals(nullableSex.validate(null), true);
        assertEquals(nullableSex.validate(undefined), false);
    });

    // fail
    await t.step('optionalNullableObj', () => {
        assertEquals(optionalNullableObj.parse({}), {});
        assertEquals(optionalNullableObj.parse({ sex: 'male' }), { sex: 'male' });
        assertEquals(optionalNullableObj.parse({ sex: null }), { sex: null });
    });
});

const sexJsonSchema = {
    type: 'string',
    enum: ['male', 'female'],
    title: 'Sex',
};

const nullableSexJsonSchema = {
    anyOf: [sexJsonSchema, { type: 'null' }],
};

// Required nullable enum
{
    const a = s.fromJSON({
        type: 'object',
        properties: {
            sex: nullableSexJsonSchema,
        },
        required: ['sex'],
    });

    const b = s.object({
        sex: s.enum(['male', 'female']).nullable(),
    });

    console.log(a.validate({ sex: null })); // true
    console.log(a.validate({ sex: 'male' })); // true

    console.log(b.validate({ sex: null })); // false
    console.log(b.validate({ sex: 'male' })); // false

    console.log(a.schema, b.schema);
}

// Optional nullable enum
{
    const a = s.fromJSON({
        type: 'object',
        properties: {
            sex: nullableSexJsonSchema,
        },
    });

    const b = s.object({
        sex: s.enum(['male', 'female']).nullable().optional(),
    });

    console.log(a.validate({})); // true
    console.log(a.validate({ sex: null })); // true

    console.log(b.validate({})); // false
    console.log(b.validate({ sex: null })); // false

    console.log(a.schema, b.schema);
}
PandaWorker commented 2 months ago

Union is also not working

    const schema1 = s.fromJSON({
        anyOf: [{ const: 'lt' }, { const: 'gt' }],
    });

    const schema2 = s.union([s.literal('lt'), s.literal('gt')]);

    // { anyOf: [ undefined ] }
    console.log(schema2.schema);

    console.log(schema1.parse('gt')); // gt
    console.log(schema1.parse('lt')); // lt

    console.log(schema2.parse('gt')); // schema is invalid: data/anyOf/0 must be object,boolean
PandaWorker commented 2 months ago

By default, all properties should be required, but here they are all optional

{
    const schema1 = s.fromJSON({
        type: 'object',
        properties: {
            name: {
                type: 'string',
                minLength: 2,
                maxLength: 20,
            },
            age: {
                type: 'integer',
                minimum: 0,
                maximum: 100,
            },
            email: {
                anyOf: [{ type: 'string', format: 'email' }, { type: 'null' }],
                default: null,
            },
            phone: {
                anyOf: [{ type: 'string' }, { type: 'null' }],
            },
            surname: {
                anyOf: [{ type: 'string' }, { type: 'null' }],
            },
        },
        required: ['name', 'age', 'surname'],
        additionalProperties: false,
    });

    const schema2 = s.object({
        name: s.string().minLength(2).maxLength(20),
        age: s.number().integer().min(0).max(100),

        // default optional - string | null | undefined
        email: s.string().format('email').nullable().optional().default(null),

        //optional - string | null | undefined
        phone: s.string().nullable().optional(),

        // required - string | null
        surname: s.string().nullable(),
    }).strict();

    console.log(schema1.schema);
    console.log(schema2.schema);

    console.log(schema1.parse({ name: 'Alex', age: 10, surname: null}));
    console.log(schema2.parse({ name: 'Alex', age: 10,})); // pass? { name: 'Alex', age: 10, email: null }
}

Ajv-ts schema

{
  type: 'object',
  properties: {
    name: { type: 'string', minLength: 2, maxLength: 20 },
    age: { type: 'integer', minimum: 0, maximum: 100 },
    email: {
      type: [ 'string', 'null' ],
      format: 'email',
      nullable: true,
      default: null
    },
    phone: { type: [ 'string', 'null' ], nullable: true },
    surname: { type: [ 'string', 'null' ], nullable: true }
  },
  additionalProperties: false
}
vitalics commented 2 months ago

@PandaWorker thanks for issue creating - I'll fix it as soon as possible

vitalics commented 1 month ago

fix will be shipped on next release 🎉