diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ProcessEntryHandler.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ProcessEntryHandler.groovy index 335eb22fac..0ed48761c8 100644 --- a/modules/nextflow/src/main/groovy/nextflow/script/ProcessEntryHandler.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/script/ProcessEntryHandler.groovy @@ -53,6 +53,20 @@ import org.codehaus.groovy.runtime.typehandling.GroovyCastException @CompileStatic class ProcessEntryHandler { + /** + * Declared numeric input types a param value is converted to (see {@link #asNumberType}). + * Boolean and Path are handled separately: Boolean accepts only the explicit `true`/`false` + * spellings rather than Groovy truthiness, and Path resolution also validates existence. + */ + private static final List NUMBER_TYPES = [ + Integer, Long, Short, Byte, Float, Double, BigInteger, BigDecimal, Number ] + + /** Numeric types that cannot represent a fractional value. */ + private static final List INTEGRAL_TYPES = [ Integer, Long, Short, Byte, BigInteger ] + + /** Non-numeric declared types converted with a plain cast. */ + private static final List SCALAR_TYPES = [ String, Character ] + private final BaseScript script private final Session session private final ProcessDef processDef @@ -374,40 +388,57 @@ class ProcessEntryHandler { if( value instanceof Collection || value instanceof Map ) return asType(value, param) - if( value !instanceof CharSequence ) - return value - - final str = value.toString() - if( type == Boolean ) { - if( str.toLowerCase() == 'true' ) return Boolean.TRUE - if( str.toLowerCase() == 'false' ) return Boolean.FALSE + if( value instanceof Boolean ) + return value + final str = value.toString().toLowerCase() + if( str == 'true' ) return Boolean.TRUE + if( str == 'false' ) return Boolean.FALSE + return value } - if( type == Integer || type == Float ) { - if( str.isInteger() ) return str.toInteger() - if( str.isLong() ) return str.toLong() - if( str.isBigInteger() ) return str.toBigInteger() - } + if( type == Path ) + return value instanceof Path ? value : TypeHelper.asPathType(value.toString()) - if( type == Float ) { - if( str.isFloat() ) return str.toFloat() - if( str.isDouble() ) return str.toDouble() - if( str.isBigDecimal() ) return str.toBigDecimal() - } + // Coerce a scalar to the DECLARED type. A value reaches here either as text (the CLI + // passes every param as a String) or as another scalar when supplied programmatically + // (JSON, for instance, yields BigDecimal for `96.4` and Integer for `40`), and neither + // necessarily matches the declaration. Converting to "whatever the text looks like" + // instead left `Double` unhandled altogether and could hand a `Float` input an Integer, + // so the wrong type reached the task and was reported as an invalid argument type. + if( type in NUMBER_TYPES ) + return asNumberType(value, param) - if( type == Path ) { - return TypeHelper.asPathType(str) - } + if( type in SCALAR_TYPES ) + return asType(value, param) return value } + /** + * Convert a param value to the declared numeric type. Text is parsed as a decimal number + * first (Groovy's cast does not parse strings into numbers), and an integral declared type + * rejects fractional text rather than silently truncating it. + */ + private static Object asNumberType(Object value, ProcessInput param) { + try { + final number = value instanceof Number + ? (Number) value + : new BigDecimal(value.toString().trim()) + if( param.type in INTEGRAL_TYPES && new BigDecimal(number.toString()).stripTrailingZeros().scale() > 0 ) + throw new NumberFormatException("Not an integral value: ${number}") + return TypeHelper.asType(number, param.type) + } + catch( NumberFormatException | GroovyCastException | UnsupportedOperationException e ) { + throw new IllegalArgumentException("Parameter `--${param.name}` with type ${Types.getName(param.type)} cannot be assigned to ${value} [${Types.getName(value.getClass())}]") + } + } + private static Object asType(Object value, ProcessInput param) { try { return TypeHelper.asType(value, param.type) } - catch( GroovyCastException | UnsupportedOperationException e ) { + catch( GroovyCastException | UnsupportedOperationException | NumberFormatException e ) { final actualType = value.getClass() throw new IllegalArgumentException("Parameter `--${param.name}` with type ${Types.getName(param.type)} cannot be assigned to ${value} [${Types.getName(actualType)}]") } diff --git a/modules/nextflow/src/test/groovy/nextflow/script/ProcessEntryHandlerTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/ProcessEntryHandlerTest.groovy index 1952f4a108..f49624a400 100644 --- a/modules/nextflow/src/test/groovy/nextflow/script/ProcessEntryHandlerTest.groovy +++ b/modules/nextflow/src/test/groovy/nextflow/script/ProcessEntryHandlerTest.groovy @@ -29,6 +29,7 @@ import nextflow.script.params.v2.ProcessTupleInput import nextflow.script.types.Record import nextflow.util.RecordMap import spock.lang.Specification +import spock.lang.Unroll /** * Tests for ProcessEntryHandler parameter mapping functionality @@ -384,6 +385,76 @@ class ProcessEntryHandlerTest extends Specification { result == null } + @Unroll + def 'should convert a #value.class.simpleName param to the declared #type.simpleName input type (v2)' () { + given: 'a typed input whose declared type differs from the supplied value type' + def meta = Mock(ScriptMeta) { getLocalProcessNames() >> [ 'hello' ] } + def handler = new ProcessEntryHandler(Mock(BaseScript), Mock(Session), meta) + + when: + def result = handler.getValueForInputV2(new ProcessInput('x', type, false), [x: value]) + + then: 'the value is converted to the DECLARED type, not to whatever the value looks like' + result.class == type + result == expected + + where: + type | value || expected + // a JSON/programmatic value: `96.4` parses as BigDecimal, `40` as Integer + Double | 96.4G || 96.4d + Double | 40 || 40.0d + Float | 96.4G || 96.4f + Integer | 40G || 40 + Long | 40 || 40L + // a CLI value: every param arrives as text + Double | '96.4' || 96.4d + Double | '40' || 40.0d + Float | '40' || 40.0f + Integer | '40' || 40 + Long | '40' || 40L + BigDecimal | '96.4' || 96.4G + String | 96.4G || '96.4' + } + + @Unroll + def 'should reject the param value #value for a declared #type.simpleName input (v2)' () { + given: + def meta = Mock(ScriptMeta) { getLocalProcessNames() >> [ 'hello' ] } + def handler = new ProcessEntryHandler(Mock(BaseScript), Mock(Session), meta) + + when: + handler.getValueForInputV2(new ProcessInput('n50', type, false), [n50: value]) + + then: 'the mismatch is reported up front instead of reaching the task' + def e = thrown(IllegalArgumentException) + e.message == "Parameter `--n50` with type ${type.simpleName} cannot be assigned to ${value} [String]" + + where: + type | value + Integer | 'abc' + Integer | '3.7' // fractional text is not silently truncated + Long | '1e' + Double | 'abc' + } + + @Unroll + def 'should keep boolean and path input handling (v2)' () { + given: + def meta = Mock(ScriptMeta) { getLocalProcessNames() >> [ 'hello' ] } + def handler = new ProcessEntryHandler(Mock(BaseScript), Mock(Session), meta) + + expect: + handler.getValueForInputV2(new ProcessInput('x', type, false), [x: value]) == expected + + where: + type | value || expected + Boolean | 'true' || true + Boolean | 'TRUE' || true + Boolean | 'false' || false + Boolean | true || true + Boolean | 'yes' || 'yes' // unchanged: no Groovy truthiness for an unknown spelling + } + def 'should throw error for missing required input (v2)' () { given: def session = Mock(Session)