diff --git a/README.md b/README.md index 8d63800..efef75e 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,10 @@ The Clamp extension introduces range-clamping scalar functions to DuckDB. Initia It currently provides: - `clamp(value, min, max)` — clamp to an arbitrary range +- `clip(value, min, max)` - alias for `clamp` - `saturate(value)` — clamp to the `[0, 1]` range - `clamp01(value)` — alias for `saturate`, common in graphics and ML +- `wrap(value, min, max)` - Wrap a value x into the range [min_val, max_val) using modular arithmetic. All functions are implemented as native DuckDB scalar functions with vectorized execution for high performance. @@ -28,15 +30,16 @@ vectorized execution for high performance. - Native DuckDB scalar functions (`ScalarFunctionSet`) - Vectorized execution via DuckDB executors - - `TernaryExecutor` for `clamp` + - `TernaryExecutor` for `clamp` / `clip` / `wrap` - `UnaryExecutor` for `saturate` / `clamp01` - Supports: - - `BIGINT` (`int64_t`) — `clamp`, `saturate1`, `clamp01` - - `DOUBLE` — `clamp`, `saturate`, `clamp01` + - `BIGINT` (`int64_t`) — `clamp`, `saturate1`, `clamp01`, `clip`, `wrap` + - `DOUBLE` — `clamp`, `saturate`, `clamp01`, `clip`, `wrap` - NULL-safe: - returns `NULL` if any input argument is `NULL` - Strict validation: - - `clamp` throws an error if `min > max` + - `clamp` / `clip` throws an error if `min > max` + - `wrap` throws an error if `min >= max` --- @@ -119,6 +122,26 @@ SELECT clamp(15, 20, 10); --- +### clip(value, min, max) + +Alias for clamp - restricts a value to an inclusive minimum and maximum bound. + +```sql +-- Value within bounds +SELECT clip(15, 10, 20); +-- 15 + +-- Below minimum +SELECT clip(5, 10, 20); +-- 10 + +-- Above maximum +SELECT clip(25, 10, 20); +-- 20 +``` + +--- + ### saturate(value) A specialized clamp that restricts a value to the `[0, 1]` range. @@ -159,15 +182,42 @@ SELECT clamp01(0.25); SELECT clamp01(2.0); -- 1.0 ``` +--- +### wrap(value, min, max) + +Wrap a value x into the range [min_val, max_val) using modular arithmetic. + +Definition: + +WRAP(x, min_val, max_val) = min_val + ((x - min_val) % (max_val - min_val)) + +```sql +SELECT wrap(25, 10, 20); +--15 +SELECT wrap(45, 10, 20); +--15 + +SELECT wrap(5, 10, 20); +--15 + +SELECT wrap(-15, 10, 20); +--15 + +SELECT wrap(370, 0, 360), wrap(-10, 0, 360), wrap(720, 0, 360); + +-- 10.0 350.0 0.0 +``` --- ## Function Signatures ``` clamp(value, min, max) +clip(value, min, max) saturate(value) clamp01(value) +wrap(value, min, max) ``` ### Parameters @@ -192,6 +242,8 @@ All arguments must be of the same type. | clamp | ✓ | ✓ | | saturate | ✓ | ✓ | | clamp01 | ✓ | ✓ | +| clip | ✓ | ✓ | +| wrap | ✓ | ✓ | --- @@ -225,17 +277,13 @@ make test ## Potential Future Additions -While this extension currently provides `clamp`, `saturate`, and +While this extension currently provides `clamp`, `clip`, `wrap`, `saturate`, and `clamp01`, it intentionally leaves room for other mathematically range-based utilities that frequently appear in numerical computing, analytics, and graphics domains. Possible future additions include: -- **Wrap / Modulo Clamp** - Wraps values around a range instead of clamping them (e.g., angles, - cyclic time windows, periodic domains). - - **Ping-Pong** Reflects values back and forth between bounds, useful for oscillating ranges or bounded waveforms. @@ -246,10 +294,6 @@ Possible future additions include: - **Min-Max Normalization** Scales values from an arbitrary range into a target range. -- **Clip** - A semantic alias for `clamp`, matching terminology used in NumPy and - PyTorch. - These functions share similar characteristics: - Simple scalar logic diff --git a/src/clamp_extension.cpp b/src/clamp_extension.cpp index 2a0cab9..fedf0a7 100644 --- a/src/clamp_extension.cpp +++ b/src/clamp_extension.cpp @@ -22,7 +22,7 @@ struct ClampOperator { // Validate bounds: min_val must not be greater than max_val if (min_val > max_val) { - throw InvalidInputException("CLAMP error: Minimum bound (%s) cannot be greater than maximum bound (%s).", + throw InvalidInputException("Error: Minimum bound (%s) cannot be greater than maximum bound (%s).", std::to_string(min_val), std::to_string(max_val)); } // Clamp value: If val < min_val, return min_val; if val > max_val, return max_val; else return val @@ -71,6 +71,74 @@ static void SaturateFunction(DataChunk &args, ExpressionState &state, Vector &re UnaryExecutor::Execute(args.data[0], result, args.size(), SaturateOperator::Operation); } +//------------------------------------------------------------------------------ +// WrapOperator: Wrap a value x into the range [min_val, max_val) using modular +// arithmetic. This is useful for cyclic values like angles. +// +// Definition: +// WRAP(x, min_val, max_val) = min_val + ((x - min_val) % (max_val - min_val)) +// +// % is the floored modulus operator, which ensures the result is always in the +// range [0, max_val - min_val). +//------------------------------------------------------------------------------ +// Helper for Integers +// Uses standard modulus operator and adjusts for negative results +template +static inline typename std::enable_if::value, T>::type ModuloLogic(T offset, T range) { + T result = offset % range; + if (result < 0) + result += range; + return result; +} + +// Helper for Floating Point +// Uses std::fmod and adjusts for negative results +template +static inline typename std::enable_if::value, T>::type ModuloLogic(T offset, T range) { + T result = std::fmod(offset, range); + if (result < 0) + result += range; + return result; +} + +// Main WrapOperator that uses the appropriate ModuloLogic based on the type +struct WrapOperator { + template + static inline T Operation(T val, T min_val, T max_val) { + // Use standard is_floating_point::value for C++11 compatibility + if (std::is_floating_point::value) { + if (std::isnan(static_cast(val)) || std::isnan(static_cast(min_val)) || + std::isnan(static_cast(max_val))) { + return std::numeric_limits::quiet_NaN(); + } + } + + // Validate bounds: min_val must not be greater than or equal to max_val + if (min_val >= max_val) { + throw InvalidInputException( + "Error: Minimum bound (%s) cannot be greater than or equal to maximum bound (%s).", + std::to_string(min_val), std::to_string(max_val)); + } + + // The compiler picks the correct ModuloLogic overload at compile time + return min_val + ModuloLogic(val - min_val, max_val - min_val); + } +}; +//------------------------------------------------------------------------------ +// WrapFunction: DuckDB executor wrapper for WrapOperator +//------------------------------------------------------------------------------ +template +static void WrapFunction(DataChunk &args, ExpressionState &state, Vector &result) { + // Uses DuckDB's TernaryExecutor to apply WrapOperator::Operation to each row + // args.data[0]: value to wrap + // args.data[1]: minimum bound + // args.data[2]: maximum bound + // result: output vector + // args.size(): number of rows + TernaryExecutor::Execute(args.data[0], args.data[1], args.data[2], result, args.size(), + WrapOperator::Operation); +} + //------------------------------------------------------------------------------ // LoadInternal: Registers the clamp function(s) with DuckDB //------------------------------------------------------------------------------ @@ -99,6 +167,11 @@ static void LoadInternal(ExtensionLoader &loader) { clamp.AddFunction(double_fun); clamp.AddFunction(bigint_fun); + // Add alias for clamp + ScalarFunctionSet clamp_alias("clip"); + clamp_alias.AddFunction(double_fun); + clamp_alias.AddFunction(bigint_fun); + // ------------------------------------------------------------------------------ // SATURATE // ------------------------------------------------------------------------------ @@ -119,10 +192,30 @@ static void LoadInternal(ExtensionLoader &loader) { saturate_alias.AddFunction(double_sat_fun); saturate_alias.AddFunction(bigint_sat_fun); + // ------------------------------------------------------------------------------ + // WRAP + // ------------------------------------------------------------------------------ + ScalarFunctionSet wrap("wrap"); + + // Define wrap for DOUBLE type + auto double_wrap_fun = ScalarFunction({LogicalType::DOUBLE, LogicalType::DOUBLE, LogicalType::DOUBLE}, + LogicalType::DOUBLE, WrapFunction); + double_wrap_fun.null_handling = FunctionNullHandling::DEFAULT_NULL_HANDLING; + + // Define wrap for BIGINT (int64_t) type + auto bigint_wrap_fun = ScalarFunction({LogicalType::BIGINT, LogicalType::BIGINT, LogicalType::BIGINT}, + LogicalType::BIGINT, WrapFunction); + bigint_wrap_fun.null_handling = FunctionNullHandling::DEFAULT_NULL_HANDLING; + + wrap.AddFunction(double_wrap_fun); + wrap.AddFunction(bigint_wrap_fun); + // Register the function set with DuckDB loader.RegisterFunction(clamp); + loader.RegisterFunction(clamp_alias); loader.RegisterFunction(saturate); loader.RegisterFunction(saturate_alias); + loader.RegisterFunction(wrap); } //------------------------------------------------------------------------------ diff --git a/test/sql/clamp.test b/test/sql/clamp.test index 7698d7a..9b2fe79 100644 --- a/test/sql/clamp.test +++ b/test/sql/clamp.test @@ -117,7 +117,121 @@ NaN statement error SELECT clamp(15, 20, 10); ---- -CLAMP error: Minimum bound (20) cannot be greater than maximum bound (10). +Error: Minimum bound (20) cannot be greater than maximum bound (10). + +# ------------------------------------------------------------------------------ +# CLIP TESTS +# ------------------------------------------------------------------------------ + +# Confirm the extension works +query I +SELECT clip(15, 10, 20); +---- +15 + +# Test: value below minimum +query I +SELECT clip(5, 10, 20); +---- +10 + +# Test: value above maximum +query I +SELECT clip(25, 10, 20); +---- +20 + +# Test: value equal to minimum +query I +SELECT clip(10, 10, 20); +---- +10 + +# Test: value equal to maximum +query I +SELECT clip(20, 10, 20); +---- +20 + +# Test: minimum equals maximum +query I +SELECT clip(15, 10, 10); +---- +10 + +# Test: negative numbers +query I +SELECT clip(-5, -10, 0); +---- +-5 + +# Test: value below negative minimum +query I +SELECT clip(-15, -10, 0); +---- +-10 + +# Test: value above zero maximum +query I +SELECT clip(5, -10, 0); +---- +0 + +# Test: floating point values +query I +SELECT clip(3.14, 2.71, 4.0); +---- +3.14 + +# Test: floating point below minimum +query I +SELECT clip(2.0, 2.71, 4.0); +---- +2.71 + +# Test: floating point above maximum +query I +SELECT clip(5.0, 2.71, 4.0); +---- +4.0 + +# Test: NULL input returns NULL +query I +SELECT clip(NULL, 10, 20); +---- +NULL + +query I +SELECT clip(15, NULL, 20); +---- +NULL + +query I +SELECT clip(15, 10, NULL); +---- +NULL + +# Test: NaN propagation +query I +SELECT clip(CAST('NaN' AS DOUBLE), 10, 20); +---- +NaN + +query I +SELECT clip(15, CAST('NaN' AS DOUBLE), 20); +---- +NaN + +query I +SELECT clip(15, 10, CAST('NaN' AS DOUBLE)); +---- +NaN + +# Test: minimum greater than maximum (should throw error) +statement error +SELECT clip(15, 20, 10); +---- +Error: Minimum bound (20) cannot be greater than maximum bound (10). # ------------------------------------------------------------------------------ # SATURATE TESTS (clamp to [0.0, 1.0]) @@ -192,3 +306,85 @@ query I SELECT clamp01(NULL); ---- NULL + +# ------------------------------------------------------------------------------ +# WRAP TESTS +# ------------------------------------------------------------------------------ + +# Test: Inside range (no change) +query I +SELECT wrap(15, 10, 20); +---- +15 + +# Test: Exact boundary (min returns min, max wraps to min) +query II +SELECT wrap(10, 10, 20), wrap(20, 10, 20); +---- +10 10 + +# Test: Wrapping forward (one full period) +query I +SELECT wrap(25, 10, 20); +---- +15 + +# Test: Wrapping forward (multiple periods) +query I +SELECT wrap(45, 10, 20); +---- +15 + +# Test: Wrapping backward (negative offset) +query I +SELECT wrap(5, 10, 20); +---- +15 + +# Test: Wrapping backward (multiple periods) +query I +SELECT wrap(-15, 10, 20); +---- +15 + +# Test: 360-degree wrapping (Angles) +query III +SELECT wrap(370, 0, 360), wrap(-10, 0, 360), wrap(720, 0, 360); +---- +10.0 350.0 0.0 + +# Test: Negative range wrapping +query I +SELECT wrap(-11, -10, -5); +---- +-6 + +# Test: NULL handling (should return NULL if any arg is NULL) +query III +SELECT wrap(NULL, 10, 20), wrap(15, NULL, 20), wrap(15, 10, NULL); +---- +NULL NULL NULL + +# Test: NaN propagation +query I +SELECT wrap(CAST('NaN' AS DOUBLE), 0, 10); +---- +NaN + +# Test: Minimum >= Maximum (should throw error) +statement error +SELECT wrap(15, 20, 10); +---- +Error: Minimum bound (20) cannot be greater than or equal to maximum bound (10). + +# Test: Minimum equals Maximum (should throw error) +statement error +SELECT wrap(15, 10, 10); +---- +Error: Minimum bound (10) cannot be greater than or equal to maximum bound (10). + +# Test: Precision edge case +query I +SELECT wrap(19.99999999999999, 10, 20); +---- +19.99999999999999 \ No newline at end of file