///////////////////////////////////////////////////////////////////////////////// // // Copyright (C) 2026- Equinor ASA // // ResInsight is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // ResInsight is distributed in the hope that it will be useful, but WITHOUT ANY // WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. // // See the GNU General Public License at // for more details. // ///////////////////////////////////////////////////////////////////////////////// #include "RimWorkflowSchemaTools.h" #include #include #include #include #include //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- QJsonObject RimWorkflowSchemaTools::resolveRef( const QString& ref, const QJsonObject& rootSchema ) { const QString prefix = "#/$defs/"; if ( !ref.startsWith( prefix ) ) return {}; return rootSchema.value( "$defs" ).toObject().value( ref.mid( prefix.size() ) ).toObject(); } //-------------------------------------------------------------------------------------------------- /// Follow $ref and unwrap single-item allOf, keeping the sibling keywords of a $ref (description, default) //-------------------------------------------------------------------------------------------------- QJsonObject RimWorkflowSchemaTools::resolveReferences( QJsonObject schema, const QJsonObject& rootSchema ) { constexpr int maxDepth = 16; for ( int depth = 0; depth < maxDepth; ++depth ) { if ( schema.contains( "$ref" ) ) { QJsonObject target = resolveRef( schema.value( "$ref" ).toString(), rootSchema ); schema.remove( "$ref" ); for ( auto it = schema.begin(); it != schema.end(); ++it ) { if ( !target.contains( it.key() ) ) target.insert( it.key(), it.value() ); } schema = target; continue; } const QJsonArray allOf = schema.value( "allOf" ).toArray(); if ( allOf.size() == 1 ) { schema = allOf.first().toObject(); continue; } break; } return schema; } //-------------------------------------------------------------------------------------------------- /// Follow $ref and unwrap single-item allOf and Optional (anyOf with null) to the underlying type //-------------------------------------------------------------------------------------------------- QJsonObject RimWorkflowSchemaTools::underlyingSchema( QJsonObject schema, const QJsonObject& rootSchema ) { constexpr int maxDepth = 16; for ( int depth = 0; depth < maxDepth; ++depth ) { if ( schema.contains( "$ref" ) ) { schema = resolveRef( schema.value( "$ref" ).toString(), rootSchema ); continue; } const QJsonArray allOf = schema.value( "allOf" ).toArray(); if ( allOf.size() == 1 ) { schema = allOf.first().toObject(); continue; } if ( schema.value( "anyOf" ).isArray() ) { QJsonArray nonNull; for ( const QJsonValue& arm : schema.value( "anyOf" ).toArray() ) { if ( arm.toObject().value( "type" ).toString() != "null" ) nonNull.append( arm ); } if ( nonNull.size() == 1 ) { schema = nonNull.first().toObject(); continue; } } break; } return schema; } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- bool RimWorkflowSchemaTools::isOpaque( const QJsonObject& schema, const QJsonObject& rootSchema ) { return underlyingSchema( schema, rootSchema ).value( "x-taskmaestro-opaque" ).toBool( false ); } //-------------------------------------------------------------------------------------------------- /// Python type of a runtime-only object, either bare or wrapped in an ObjectModel `value` field //-------------------------------------------------------------------------------------------------- QString RimWorkflowSchemaTools::objectPythonType( const QJsonObject& typeSchema, const QJsonObject& rootSchema ) { if ( typeSchema.contains( "x-taskmaestro-python-type" ) ) return typeSchema.value( "x-taskmaestro-python-type" ).toString(); const QJsonObject valueSchema = typeSchema.value( "properties" ).toObject().value( "value" ).toObject(); if ( valueSchema.isEmpty() ) return {}; return underlyingSchema( valueSchema, rootSchema ).value( "x-taskmaestro-python-type" ).toString(); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- QString RimWorkflowSchemaTools::jsonType( const QJsonObject& typeSchema ) { const QString type = typeSchema.value( "type" ).toString(); if ( type == "boolean" || type == "integer" || type == "number" || type == "array" ) return type; // int | float const QJsonArray anyOf = typeSchema.value( "anyOf" ).toArray(); if ( !anyOf.isEmpty() && std::all_of( anyOf.begin(), anyOf.end(), []( const QJsonValue& arm ) { const QString armType = arm.toObject().value( "type" ).toString(); return armType == "integer" || armType == "number"; } ) ) return "number"; // Any: the value is typed as a literal for ( const char* key : { "type", "anyOf", "oneOf", "allOf", "enum", "const", "$ref", "properties", "items" } ) { if ( typeSchema.contains( key ) ) return "string"; } return "any"; } //-------------------------------------------------------------------------------------------------- /// A value typed for an untyped (Any) field, read like a YAML scalar: true/false, numbers, /// null/None/~ and JSON arrays, objects and quoted strings. Other text is a string; empty text is null. //-------------------------------------------------------------------------------------------------- QJsonValue RimWorkflowSchemaTools::literalFromText( const QString& text ) { const QString token = text.trimmed(); const QString lower = token.toLower(); if ( token.isEmpty() || lower == "null" || lower == "none" || token == "~" ) return QJsonValue( QJsonValue::Null ); if ( lower == "true" ) return true; if ( lower == "false" ) return false; const QJsonDocument document = QJsonDocument::fromJson( ( "[" + token + "]" ).toUtf8() ); if ( document.isArray() && document.array().size() == 1 ) return document.array().first(); return token; } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- QString RimWorkflowSchemaTools::explicitNullKey() { return "explicit_null"; } //-------------------------------------------------------------------------------------------------- /// The text that `literalFromText` reads back as `value`; null is written as `null` //-------------------------------------------------------------------------------------------------- QString RimWorkflowSchemaTools::textFromLiteral( const QJsonValue& value ) { if ( value.isString() && literalFromText( value.toString() ) == value ) return value.toString(); if ( value.isUndefined() ) return {}; const QByteArray json = QJsonDocument( QJsonArray{ value } ).toJson( QJsonDocument::Compact ); return QString::fromUtf8( json.mid( 1, json.size() - 2 ) ); } //-------------------------------------------------------------------------------------------------- /// taskmaestro_resinsight.models.Vec3 is a plain pydantic model (x, y, z numbers), so it has no /// x-taskmaestro-python-type tag; recognize it structurally instead. //-------------------------------------------------------------------------------------------------- bool RimWorkflowSchemaTools::isVec3Schema( const QJsonObject& typeSchema ) { if ( typeSchema.value( "type" ).toString() != "object" ) return false; const QJsonObject properties = typeSchema.value( "properties" ).toObject(); if ( properties.size() != 3 ) return false; for ( const QString& component : QStringList{ "x", "y", "z" } ) { if ( properties.value( component ).toObject().value( "type" ).toString() != "number" ) return false; } return true; } //-------------------------------------------------------------------------------------------------- /// Map a Python class path such as `rips.generated.generated_classes.WellPath` to the ResInsight /// object type used to select the field binding. Returns an empty string for unsupported types. //-------------------------------------------------------------------------------------------------- QString RimWorkflowSchemaTools::resinsightTypeFromPythonType( const QString& pythonType ) { const int separator = pythonType.lastIndexOf( '.' ); if ( separator < 0 ) return {}; const QString module = pythonType.left( separator ); if ( module != "rips" && !module.startsWith( "rips." ) ) return {}; static const QMap typeMap = { { "Case", "EclipseCase" }, { "Reservoir", "EclipseCase" }, { "EclipseCase", "EclipseCase" }, { "WellPath", "WellPath" }, { "View", "View" }, { "EclipseView", "View" } }; return typeMap.value( pythonType.mid( separator + 1 ) ); } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- QStringList RimWorkflowSchemaTools::requiredFields( const QJsonObject& modelSchema ) { QStringList fields; for ( const QJsonValue& value : modelSchema.value( "required" ).toArray() ) fields.append( value.toString() ); return fields; } //-------------------------------------------------------------------------------------------------- /// Property names in schema order //-------------------------------------------------------------------------------------------------- QStringList RimWorkflowSchemaTools::propertyNames( const QJsonObject& modelSchema ) { // QJsonObject sorts its keys; the `required` list and the original document keep model order QStringList names; const QJsonObject properties = modelSchema.value( "properties" ).toObject(); for ( const QString& name : requiredFields( modelSchema ) ) { if ( properties.contains( name ) && !names.contains( name ) ) names.append( name ); } for ( const QString& name : properties.keys() ) { if ( !names.contains( name ) ) names.append( name ); } return names; } //-------------------------------------------------------------------------------------------------- /// Whether a value for the input property can be entered in ResInsight (a literal or an object reference) //-------------------------------------------------------------------------------------------------- bool RimWorkflowSchemaTools::isConfigurable( const QJsonObject& property, const QJsonObject& rootSchema ) { const QJsonObject typeSchema = underlyingSchema( property, rootSchema ); const QString pythonType = objectPythonType( typeSchema, rootSchema ); if ( !pythonType.isEmpty() ) { // An object model with more required fields than `value` cannot be given as a single object QStringList required = requiredFields( typeSchema ); required.removeAll( "value" ); return required.isEmpty() && !resinsightTypeFromPythonType( pythonType ).isEmpty(); } if ( typeSchema.value( "x-taskmaestro-opaque" ).toBool( false ) ) return false; if ( isVec3Schema( typeSchema ) ) return true; return typeSchema.value( "type" ).toString() != "object"; } //-------------------------------------------------------------------------------------------------- /// //-------------------------------------------------------------------------------------------------- QJsonObject RimWorkflowSchemaTools::configFieldSchema( const QString& fieldName, const QJsonObject& inputSchema, const QStringList& requiredFields, const QJsonObject& configValues ) { QJsonObject entry{ { "name", fieldName } }; const QJsonObject properties = inputSchema.value( "properties" ).toObject(); if ( !properties.contains( fieldName ) ) { entry["type"] = "string"; entry["required"] = true; entry["error"] = "field not found in input model"; return entry; } const QJsonObject property = properties.value( fieldName ).toObject(); const QJsonObject typeSchema = underlyingSchema( property, inputSchema ); QString resinsightType = resinsightTypeFromPythonType( objectPythonType( typeSchema, inputSchema ) ); if ( resinsightType.isEmpty() && isVec3Schema( typeSchema ) ) resinsightType = "Vec3"; entry["type"] = resinsightType.isEmpty() ? jsonType( typeSchema ) : "object"; entry["required"] = requiredFields.contains( fieldName ); const QString description = property.value( "description" ).toString(); if ( !description.isEmpty() ) entry["description"] = description; // Prefer the value already in input.yaml; fall back to the model default if ( configValues.contains( fieldName ) ) { entry["default"] = configValues.value( fieldName ); // A null in input.yaml is an entered None, unlike a model default of None if ( entry["default"].isNull() ) entry[explicitNullKey()] = true; } else if ( property.contains( "default" ) ) entry["default"] = property.value( "default" ); const QString format = typeSchema.value( "format" ).toString(); if ( !format.isEmpty() ) entry["format"] = format; if ( !resinsightType.isEmpty() ) entry["resinsight_type"] = resinsightType; return entry; }