Skip to content

[Due for payment 2025-11-26] [Due for payment 2025-11-17] [CRN-QA] report field initial value set to formula referencing itself #70775

Description

@neil-marcellini

Issue Reported by : Applause Internal Team

Action performed

  1. Create a workspace and expense report on the workspace
  2. Set the default report title to "Report field value {field:Test text}"
  3. Create a text report field on the policy named "Test text" with the initial value set to "initial"
  4. Update the initial value to "{field:Test text}"
  5. Open the initial value page again
  6. Delete the report field
  7. Open the policy expense chat

Expected result

  • Setting the initial value of a report field, or the value of a report field, to a text value which is a formula referencing the field itself, and therefore creating a circular reference, should result in an error and the new value should not be saved
    • Note: On Classic, setting the initial value of a report field to a valid formula makes the report field a "formula" type, and its value can not be manually changed on the report.
  • If a report field initial value is a formula that references itself, and it is already saved in the database, because it is currently allowed on Expensify Classic, then the type of the returned report field should be changed to "text" so that it is not computed

The initial value page is not blank, and when opening the policy expense chat the app does not crash

Actual result

The initial value page is blank, and the app crashes when opening the policy expense chat

490105214-64e7dfec-1f75-472e-92ae-1bbab24580e6.mp4

OldDot behavior

2025-10-01_08-10-53.mp4
Backend code for identifying circular references

First circular references are checked for the whole formula, but on the frontend you could check passing only one "part" such as {field:someField}, and no parent fields.

string Formula::compute(SQLite& db, const JSON::Value& reportJSON, const UserDefinedFieldFormula* formula,
                        map<int64_t, UserCache>* userCacheMap, map<string, PolicyCache>* policyCacheMap, map<int64_t, ReportCache>* reportCacheMap, const JSON::Value& transactionJSON, const bool preventDeprecatedFormulas)
{
    SDEBUG("[Formula::compute] defaultValue: " << formula->getDefaultValue().value());
    const auto parts = parse(formula->getFormula());

    const int64_t reportID = reportJSON.getIntMemberWithDefault("reportID", 0);
    if (hasCircularReferences(db, reportID, parts, {})) {
        SINFO("Formula has circular references, omitting");
        return "";
    }


bool Formula::hasCircularReferences(SQLite& db, int64_t reportID, const vector<unique_ptr<FormulaPart>>& parts, const vector<string>& parentFields)
{
    // Prevent excessive recursion depth that could cause stack overflow
    if (parentFields.size() > MAX_CIRCULAR_REFERENCE_DEPTH) {
        SHMMM("Circular reference depth exceeds maximum allowed", {{"depth", to_string(parentFields.size())}, {"maxDepth", to_string(MAX_CIRCULAR_REFERENCE_DEPTH)}});

        // Assume circular reference to prevent further recursion
        return true;
    }

    for (const auto& part : parts) {
        if (part->getType() != FormulaPart::TYPE_FIELD) {
            continue;
        }

        auto* fieldPart = static_cast<FieldPart*>(part.get());
        const optional<unique_ptr<UserDefinedField>>& field = fieldPart->getField(db, reportID);
        if (!field.has_value()) {
            continue;
        }

        auto* formulaField = dynamic_cast<UserDefinedFieldFormula*>(field.value().get());
        if (!formulaField) {
            continue;
        }

        const string fieldID = formulaField->getID();
        if (find(parentFields.begin(), parentFields.end(), fieldID) != parentFields.end()) {
            return true;
        }

        vector<string> newParentFields = parentFields;
        newParentFields.push_back(fieldID);
        if (formulaField->hasCircularReferences(db, reportID, newParentFields)) {
            return true;
        }
    }
    return false;
}



optional<unique_ptr<UserDefinedField>> FieldPart::getField(SQLite& db, int64_t reportID)
{
    if (fieldPath.size() == 0) {
        return nullopt;
    }

    string name = fieldPath[0];
    auto fieldID = UserDefinedField::createFromDatabaseData<UserDefinedField>(JSON::Value({{"name", STrim(name)}, {"type", UserDefinedField::UDF_TYPE_TEXT}}))->getID();
    auto udfs = Report::getUserDefinedFields(db, reportID);

    for (auto& udf : udfs) {
        if (udf->getID() == fieldID) {
            return make_optional(move(udf));
        }
    }

    return nullopt;
}
Issue OwnerCurrent Issue Owner: @sonialiap

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2DesignInternalRequires API changes or must be handled by Expensify staff

Type

No type
No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions