// Cannot implicitly shard accessed collections because of collection existing when none
// expected.
// @tags: [
//   assumes_no_implicit_collection_creation_after_drop,
//   requires_non_retryable_commands,
//   requires_non_retryable_writes,
//   requires_fastcount,
//   requires_fcv_47,
// ]

// Test basic inserts and updates with document validation.
(function() {
"use strict";
load("jstests/libs/doc_validation_utils.js");  // for assertDocumentValidationFailure

const collName = "doc_validation";
const coll = db[collName];

const array = [];
for (let i = 0; i < 2048; i++) {
    array.push({arbitrary: i});
}

/**
 * Runs a series of document validation tests using the validator 'validator', which should
 * enforce the existence of a field "a".
 */
function runInsertUpdateValidationTest(validator) {
    coll.drop();

    // Create a collection with document validator 'validator'.
    assert.commandWorked(db.createCollection(collName, {validator: validator}));
    // The default validation level/action are OMITTED from "listCollections".
    // (After a collMod they do appear, see below).
    let info = db.getCollectionInfos({name: collName})[0];
    assert.eq(info.options.validationLevel, undefined, info);
    assert.eq(info.options.validationAction, undefined, info);

    // Insert and upsert documents that will pass validation.
    assert.commandWorked(coll.insert({_id: "valid1", a: 1}));
    assert.commandWorked(coll.update({_id: "valid2"}, {_id: "valid2", a: 2}, {upsert: true}));
    assert.commandWorked(coll.runCommand(
        "findAndModify", {query: {_id: "valid3"}, update: {$set: {a: 3}}, upsert: true}));

    // Insert and upsert documents that will not pass validation.
    assertDocumentValidationFailure(coll.insert({_id: "invalid3", b: 1}), coll);
    assertDocumentValidationFailure(
        coll.update({_id: "invalid4"}, {_id: "invalid4", b: 2}, {upsert: true}), coll);
    assertDocumentValidationFailure(
        coll.runCommand("findAndModify",
                        {query: {_id: "invalid4"}, update: {$set: {b: 3}}, upsert: true}),
        coll);

    // Assert that we can remove the document that passed validation.
    assert.commandWorked(coll.remove({_id: "valid1"}));

    // Check that we can only update documents that pass validation. We insert a valid and an
    // invalid document, then set the validator.
    coll.drop();
    assert.commandWorked(coll.insert({_id: "valid1", a: 1}));
    assert.commandWorked(coll.insert({_id: "invalid2", b: 1}));
    assert.commandWorked(coll.runCommand("collMod", {validator: validator}));
    // After collMod, the default validation level/action are INCLUDED in "listCollections".
    info = db.getCollectionInfos({name: collName})[0];
    assert.eq(info.options.validationLevel, "strict", info);
    assert.eq(info.options.validationAction, "error", info);

    // Assert that updates on a conforming document succeed when they affect fields not involved
    // in validator.
    // Add a new field.
    assert.commandWorked(coll.update({_id: "valid1"}, {$set: {z: 1}}));
    assert.commandWorked(
        coll.runCommand("findAndModify", {query: {_id: "valid1"}, update: {$set: {y: 2}}}));
    // In-place update.
    assert.commandWorked(coll.update({_id: "valid1"}, {$inc: {z: 1}}));
    assert.commandWorked(
        coll.runCommand("findAndModify", {query: {_id: "valid1"}, update: {$inc: {y: 1}}}));
    // Out-of-place update.
    assert.commandWorked(coll.update({_id: "valid1"}, {$set: {z: array}}));
    assert.commandWorked(
        coll.runCommand("findAndModify", {query: {_id: "valid1"}, update: {$set: {y: array}}}));
    // No-op update.
    assert.commandWorked(coll.update({_id: "valid1"}, {a: 1}));
    assert.commandWorked(
        coll.runCommand("findAndModify", {query: {_id: "valid1"}, update: {$set: {a: 1}}}));

    // Verify those same updates will fail on non-conforming document.
    assertDocumentValidationFailure(coll.update({_id: "invalid2"}, {$set: {z: 1}}), coll);
    assertDocumentValidationFailure(coll.update({_id: "invalid2"}, {$inc: {z: 1}}), coll);
    assertDocumentValidationFailure(coll.update({_id: "invalid2"}, {$set: {z: array}}), coll);
    assertDocumentValidationFailure(
        coll.runCommand("findAndModify", {query: {_id: "invalid2"}, update: {$set: {y: 2}}}), coll);
    assertDocumentValidationFailure(
        coll.runCommand("findAndModify", {query: {_id: "invalid2"}, update: {$inc: {y: 1}}}), coll);
    assertDocumentValidationFailure(
        coll.runCommand("findAndModify", {query: {_id: "invalid2"}, update: {$set: {y: array}}}),
        coll);

    // A no-op update of an invalid doc will succeed.
    assert.commandWorked(coll.update({_id: "invalid2"}, {$set: {b: 1}}));
    assert.commandWorked(
        coll.runCommand("findAndModify", {query: {_id: "invalid2"}, update: {$set: {b: 1}}}));

    // Verify that we can't make a conforming document fail validation, but can update a
    // non-conforming document to pass validation.
    coll.drop();
    assert.commandWorked(coll.insert({_id: "valid1", a: 1}));
    assert.commandWorked(coll.insert({_id: "invalid2", b: 1}));
    assert.commandWorked(coll.insert({_id: "invalid3", b: 1}));
    assert.commandWorked(coll.runCommand("collMod", {validator: validator}));

    assertDocumentValidationFailure(coll.update({_id: "valid1"}, {$unset: {a: 1}}), coll);
    assert.commandWorked(coll.update({_id: "invalid2"}, {$set: {a: 1}}));
    assertDocumentValidationFailure(
        coll.runCommand("findAndModify", {query: {_id: "valid1"}, update: {$unset: {a: 1}}}), coll);
    assert.commandWorked(
        coll.runCommand("findAndModify", {query: {_id: "invalid3"}, update: {$set: {a: 1}}}));

    // Modify the collection to remove the document validator.
    assert.commandWorked(coll.runCommand("collMod", {validator: {}}));

    // Verify that no validation is applied to updates.
    assert.commandWorked(coll.update({_id: "valid1"}, {$set: {z: 1}}));
    assert.commandWorked(coll.update({_id: "invalid2"}, {$set: {z: 1}}));
    assert.commandWorked(coll.update({_id: "valid1"}, {$unset: {a: 1}}));
    assert.commandWorked(coll.update({_id: "invalid2"}, {$set: {a: 1}}));
    assert.commandWorked(
        coll.runCommand("findAndModify", {query: {_id: "valid1"}, update: {$set: {z: 2}}}));
    assert.commandWorked(
        coll.runCommand("findAndModify", {query: {_id: "invalid2"}, update: {$set: {z: 2}}}));
    assert.commandWorked(
        coll.runCommand("findAndModify", {query: {_id: "valid1"}, update: {$unset: {a: 1}}}));
    assert.commandWorked(
        coll.runCommand("findAndModify", {query: {_id: "invalid2"}, update: {$set: {a: 1}}}));
}

// Run the test with a normal validator.
runInsertUpdateValidationTest({a: {$exists: true}});

// Run the test again with an equivalent JSON Schema.
runInsertUpdateValidationTest({$jsonSchema: {required: ["a"]}});

/**
 * Run a series of document validation tests involving collation using the validator
 * 'validator', which should enforce that the field "a" has the value "xyz".
 */
function runCollationValidationTest(validator) {
    coll.drop();
    assert.commandWorked(db.createCollection(
        collName, {validator: validator, collation: {locale: "en_US", strength: 2}}));

    // An insert that matches the validator should succeed.
    assert.commandWorked(coll.insert({_id: 0, a: "xyz", b: "foo"}));

    const isJSONSchema = validator.hasOwnProperty("$jsonSchema");

    // A normal validator should respect the collation and the inserts should succeed. A JSON
    // Schema validator ignores the collation and the inserts should fail.
    const assertCorrectResult = isJSONSchema ? res => assertDocumentValidationFailure(res, coll)
                                             : res => assert.commandWorked(res);
    assertCorrectResult(coll.insert({a: "XYZ"}));
    assertCorrectResult(coll.insert({a: "XyZ", b: "foo"}));
    assertCorrectResult(coll.update({_id: 0}, {a: "xyZ", b: "foo"}));
    assertCorrectResult(coll.update({_id: 0}, {$set: {a: "Xyz"}}));
    assertCorrectResult(
        coll.runCommand("findAndModify", {query: {_id: 0}, update: {a: "xyZ", b: "foo"}}));
    assertCorrectResult(
        coll.runCommand("findAndModify", {query: {_id: 0}, update: {$set: {a: "Xyz"}}}));

    // Test an insert and an update that should always fail.
    assertDocumentValidationFailure(coll.insert({a: "not xyz"}), coll);
    assertDocumentValidationFailure(coll.update({_id: 0}, {$set: {a: "xyzz"}}), coll);
    assertDocumentValidationFailure(
        coll.runCommand("findAndModify", {query: {_id: 0}, update: {$set: {a: "xyzz"}}}), coll);

    // A normal validator expands leaf arrays, such that if "a" is an array containing "xyz", it
    // matches {a: "xyz"}. A JSON Schema validator does not expand leaf arrays and treats arrays
    // as a single array value.
    assertCorrectResult(coll.insert({a: ["xyz"]}));
    assertCorrectResult(coll.insert({a: ["XYZ"]}));
    assertCorrectResult(coll.insert({a: ["XyZ"], b: "foo"}));
}

runCollationValidationTest({a: "xyz"});
runCollationValidationTest({$jsonSchema: {properties: {a: {enum: ["xyz"]}}}});

// The validator is allowed to contain $expr.
coll.drop();
assert.commandWorked(db.createCollection(collName, {validator: {$expr: {$eq: ["$a", 5]}}}));
assert.commandWorked(coll.insert({a: 5}));
assertDocumentValidationFailure(coll.insert({a: 4}), coll);
assert.commandWorked(db.runCommand({"collMod": collName, "validator": {$expr: {$eq: ["$a", 4]}}}));
assert.commandWorked(coll.insert({a: 4}));
assertDocumentValidationFailure(coll.insert({a: 5}), coll);

// The validator will generate detailed errors when $expr throws.
assert.commandWorked(
    db.runCommand({"collMod": collName, "validator": {$expr: {$divide: [10, 0]}}}));
assertDocumentValidationFailure(coll.insert({a: 4}), coll);
assert.commandWorked(
    db.runCommand({"collMod": collName, "validator": {$nor: [{$expr: {$divide: [10, 0]}}]}}));
assertDocumentValidationFailure(coll.insert({a: 4}), coll);

// The validator supports $expr with the date extraction expressions (with a timezone
// specified).
coll.drop();
assert.commandWorked(db.createCollection(
    collName,
    {validator: {$expr: {$eq: [1, {$dayOfMonth: {date: "$a", timezone: "America/New_York"}}]}}}));
assert.commandWorked(coll.insert({a: ISODate("2017-10-01T22:00:00")}));
assertDocumentValidationFailure(coll.insert({a: ISODate("2017-10-01T00:00:00")}), coll);

// The validator supports $expr with a $dateToParts expression.
coll.drop();
assert.commandWorked(db.createCollection(collName, {
    validator: {
        $expr: {
            $eq: [
                {
                    "year": 2017,
                    "month": 10,
                    "day": 1,
                    "hour": 18,
                    "minute": 0,
                    "second": 0,
                    "millisecond": 0
                },
                {$dateToParts: {date: "$a", timezone: "America/New_York"}}
            ]
        }
    }
}));
assert.commandWorked(coll.insert({a: ISODate("2017-10-01T22:00:00")}));
assertDocumentValidationFailure(coll.insert({a: ISODate("2017-10-01T00:00:00")}), coll);

// The validator supports $expr with $dateToString expression.
coll.drop();
assert.commandWorked(db.createCollection(collName, {
    validator: {
        $expr: {
            $eq: [
                "2017-07-04 14:56:42 +0000 (0 minutes)",
                {
                    $dateToString: {
                        format: "%Y-%m-%d %H:%M:%S %z (%Z minutes)",
                        date: "$date",
                        timezone: "$tz"
                    }
                }
            ]
        }
    }
}));
assert.commandWorked(coll.insert({date: new ISODate("2017-07-04T14:56:42.911Z"), tz: "UTC"}));
assertDocumentValidationFailure(
    coll.insert({date: new ISODate("2017-07-04T14:56:42.911Z"), tz: "America/New_York"}), coll);

// The validator supports $expr with $dateFromParts expression.
coll.drop();
assert.commandWorked(db.createCollection(collName, {
    validator: {
        $expr: {
            $eq: [
                ISODate("2016-12-31T15:00:00Z"),
                {'$dateFromParts': {year: "$year", "timezone": "$timezone"}}
            ]
        }
    }
}));
assert.commandWorked(coll.insert({_id: 0, year: 2017, month: 6, day: 19, timezone: "Asia/Tokyo"}));
assertDocumentValidationFailure(
    coll.insert({_id: 1, year: 2022, month: 1, day: 1, timezone: "America/New_York"}), coll);

// The validator supports $expr with $dateFromString expression.
coll.drop();
assert.commandWorked(db.createCollection(collName, {
    validator: {
        $expr: {
            $eq: [
                ISODate("2017-07-04T15:56:02Z"),
                {'$dateFromString': {dateString: "$date", timezone: 'America/New_York'}}
            ]
        }
    }
}));
assert.commandWorked(coll.insert({_id: 0, date: "2017-07-04T11:56:02"}));
assertDocumentValidationFailure(coll.insert({_id: 1, date: "2015-02-02T11:00:00"}), coll);

// The validator can contain an $expr that may throw at runtime.
coll.drop();
assert.commandWorked(
    db.createCollection(collName, {validator: {$expr: {$eq: ["$a", {$divide: [1, "$b"]}]}}}));
assert.commandWorked(coll.insert({a: 1, b: 1}));
let res = coll.insert({a: 1, b: 0});
assert.writeError(res);
assert.eq(res.getWriteError().code, ErrorCodes.DocumentValidationFailure);
assert.commandWorked(coll.insert({a: -1, b: -1}));

// The validator can contain an $expr that respects the collation.
coll.drop();
// Create collection with validator that uses case insensitive collation.
assert.commandWorked(db.createCollection(
    collName,
    {validator: {$expr: {$eq: ["$a", "foobar"]}}, collation: {locale: "en", strength: 2}}));
assert.commandWorked(coll.insert({a: "foobar"}));
assert.commandWorked(coll.insert({a: "fooBAR"}));

// Recreate a collection with a simple validator.
coll.drop();
assert.commandWorked(db.createCollection(collName, {validator: {a: 1}}));

// Insert a document that fails validation.
res = coll.insert({_id: 1, a: 2});

// Verify that the document validation error attribute 'failingDocumentId' equals to the document
// '_id' attribute.
assertDocumentValidationFailure(res, coll);
if (coll.getMongo().writeMode() === 'commands') {
    const errorInfo = res.getWriteError().errInfo;
    const expectedError = {
        failingDocumentId: 1,
        details: {
            operatorName: "$eq",
            specifiedAs: {a: 1},
            reason: "comparison failed",
            consideredValue: 2
        }
    };
    assert.docEq(errorInfo, expectedError, tojson(res));
}

// Insert a valid document.
assert.commandWorked(coll.insert({_id: 1, a: 1}));

// Issues the update command and returns the response.
function updateCommand(coll, query, update) {
    return coll.update(query, update);
}

// Issues the findAndModify command and returns the response.
function findAndModifyCommand(coll, query, update) {
    return coll.runCommand("findAndModify", {query: query, update: update});
}

for (const command of [updateCommand, findAndModifyCommand]) {
    // Attempt to update the document by replacing it with a document that does not pass validation.
    const res = command(coll, {}, {a: 2});

    // Verify that the document validation error attribute 'failingDocumentId' equals to the
    // document '_id' attribute.
    assertDocumentValidationFailure(res, coll);
    if (coll.getMongo().writeMode() === 'commands') {
        const errorInfo = (res instanceof WriteResult ? res.getWriteError() : res).errInfo;
        const expectedError = {
            failingDocumentId: 1,
            details: {
                operatorName: "$eq",
                specifiedAs: {a: 1},
                reason: "comparison failed",
                consideredValue: 2
            }
        };
        assert.docEq(errorInfo, expectedError, tojson(res));
    }
}
})();
