diff --git a/changelog.d/25.added.md b/changelog.d/25.added.md new file mode 100644 index 00000000..923c0efb --- /dev/null +++ b/changelog.d/25.added.md @@ -0,0 +1 @@ +Added enhanced mathematical functions to PostgreSQL backend, including round_, pow, power, sqrt, mod, ceil, floor, trunc, max_, min_, and avg, with proper naming to avoid conflicts with Python builtins. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py index 2b3a366f..ad2e95f3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py @@ -20,6 +20,7 @@ - json: JSON functions and operators - https://www.postgresql.org/docs/current/functions-json.html - text_search: Full-text search functions - https://www.postgresql.org/docs/current/functions-textsearch.html - xml: XML functions - https://www.postgresql.org/docs/current/functions-xml.html +- math_enhanced: Enhanced math functions - https://www.postgresql.org/docs/current/functions-math.html """ # Range functions @@ -130,6 +131,21 @@ xml_is_well_formed, ) +# Math enhanced functions +from .math_enhanced import ( + round_, + pow, + power, + sqrt, + mod, + ceil, + floor, + trunc, + max_, + min_, + avg, +) + __all__ = [ # Range functions @@ -219,4 +235,16 @@ "xpath_query", "xpath_exists", "xml_is_well_formed", + # Math enhanced functions + "round_", + "pow", + "power", + "sqrt", + "mod", + "ceil", + "floor", + "trunc", + "max_", + "min_", + "avg", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/math_enhanced.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/math_enhanced.py new file mode 100644 index 00000000..0f47c41b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/math_enhanced.py @@ -0,0 +1,381 @@ +# src/rhosocial/activerecord/backend/impl/postgres/functions/math_enhanced.py +""" +PostgreSQL enhanced math function factories. + +Additional mathematical functions beyond the basic math module. +Includes: round, pow, power, sqrt, mod, ceil, floor, trunc, max, min, avg + +Functions: round_, pow, power, sqrt, mod, ceil, floor, trunc, max_, min_, avg +""" + +from typing import Union, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression import bases, core + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +def _convert_to_expression( + dialect: "SQLDialectBase", + expr: Union[str, "bases.BaseExpression"], +) -> "bases.BaseExpression": + """ + Helper function to convert an input value to an appropriate BaseExpression. + + Args: + dialect: The SQL dialect instance + expr: The expression to convert + + Returns: + A BaseExpression instance + """ + if isinstance(expr, bases.BaseExpression): + return expr + elif isinstance(expr, (int, float)): + return core.Literal(dialect, expr) + elif isinstance(expr, str): + # Try to parse as number first + try: + return core.Literal(dialect, float(expr) if '.' in expr else int(expr)) + except ValueError: + # Not a number, treat as column name + return core.Column(dialect, expr) + else: + return core.Column(dialect, expr) + + +def round_( + dialect: "SQLDialectBase", + expr: Union[str, "bases.BaseExpression"], + precision: int = 0, +) -> "core.FunctionCall": + """ + Creates a ROUND function call. + + Rounds a numeric value to the specified number of decimal places. + + Usage: + - round_(dialect, Column("price")) -> ROUND("price") + - round_(dialect, Column("price"), 2) -> ROUND("price", 2) + + Args: + dialect: The SQL dialect instance + expr: The numeric expression to round + precision: Number of decimal places (default 0) + + Returns: + A FunctionCall instance representing the ROUND function + + Version: All PostgreSQL versions + """ + target_expr = _convert_to_expression(dialect, expr) + precision_expr = core.Literal(dialect, precision) + return core.FunctionCall(dialect, "ROUND", target_expr, precision_expr) + + +def pow( + dialect: "SQLDialectBase", + base: Union[str, "bases.BaseExpression"], + exponent: Union[str, "bases.BaseExpression"], +) -> "core.FunctionCall": + """ + Creates a POW function call. + + Returns the value of base raised to the power of exponent. + + Usage: + - pow(dialect, 2, 3) -> POW(2, 3) + - pow(dialect, Column("x"), 2) -> POW("x", 2) + + Args: + dialect: The SQL dialect instance + base: The base value + exponent: The exponent value + + Returns: + A FunctionCall instance representing the POW function + + Version: All PostgreSQL versions + """ + base_expr = _convert_to_expression(dialect, base) + exp_expr = _convert_to_expression(dialect, exponent) + return core.FunctionCall(dialect, "POW", base_expr, exp_expr) + + +def power( + dialect: "SQLDialectBase", + base: Union[str, "bases.BaseExpression"], + exponent: Union[str, "bases.BaseExpression"], +) -> "core.FunctionCall": + """ + Creates a POWER function call (alias for POW). + + Returns the value of base raised to the power of exponent. + + Usage: + - power(dialect, 2, 3) -> POWER(2, 3) + - power(dialect, Column("x"), 2) -> POWER("x", 2) + + Args: + dialect: The SQL dialect instance + base: The base value + exponent: The exponent value + + Returns: + A FunctionCall instance representing the POWER function + + Version: All PostgreSQL versions + """ + base_expr = _convert_to_expression(dialect, base) + exp_expr = _convert_to_expression(dialect, exponent) + return core.FunctionCall(dialect, "POWER", base_expr, exp_expr) + + +def sqrt( + dialect: "SQLDialectBase", + expr: Union[str, "bases.BaseExpression"], +) -> "core.FunctionCall": + """ + Creates a SQRT function call. + + Returns the square root of the argument. + + Usage: + - sqrt(dialect, 16) -> SQRT(16) + - sqrt(dialect, Column("value")) -> SQRT("value") + + Args: + dialect: The SQL dialect instance + expr: The numeric expression + + Returns: + A FunctionCall instance representing the SQRT function + + Version: All PostgreSQL versions + """ + target_expr = _convert_to_expression(dialect, expr) + return core.FunctionCall(dialect, "SQRT", target_expr) + + +def mod( + dialect: "SQLDialectBase", + dividend: Union[str, "bases.BaseExpression"], + divisor: Union[str, "bases.BaseExpression"], +) -> "core.FunctionCall": + """ + Creates a MOD function call. + + Returns the remainder of dividend divided by divisor. + + Usage: + - mod(dialect, 10, 3) -> MOD(10, 3) + - mod(dialect, Column("total"), Column("count")) -> MOD("total", "count") + + Args: + dialect: The SQL dialect instance + dividend: The dividend value + divisor: The divisor value + + Returns: + A FunctionCall instance representing the MOD function + + Version: All PostgreSQL versions + """ + dividend_expr = _convert_to_expression(dialect, dividend) + divisor_expr = _convert_to_expression(dialect, divisor) + return core.FunctionCall(dialect, "MOD", dividend_expr, divisor_expr) + + +def ceil( + dialect: "SQLDialectBase", + expr: Union[str, "bases.BaseExpression"], +) -> "core.FunctionCall": + """ + Creates a CEIL function call. + + Returns the smallest integer greater than or equal to the argument. + + Usage: + - ceil(dialect, 3.14) -> CEIL(3.14) + - ceil(dialect, Column("value")) -> CEIL("value") + + Args: + dialect: The SQL dialect instance + expr: The numeric expression + + Returns: + A FunctionCall instance representing the CEIL function + + Version: All PostgreSQL versions + """ + target_expr = _convert_to_expression(dialect, expr) + return core.FunctionCall(dialect, "CEIL", target_expr) + + +def floor( + dialect: "SQLDialectBase", + expr: Union[str, "bases.BaseExpression"], +) -> "core.FunctionCall": + """ + Creates a FLOOR function call. + + Returns the largest integer less than or equal to the argument. + + Usage: + - floor(dialect, 3.14) -> FLOOR(3.14) + - floor(dialect, Column("value")) -> FLOOR("value") + + Args: + dialect: The SQL dialect instance + expr: The numeric expression + + Returns: + A FunctionCall instance representing the FLOOR function + + Version: All PostgreSQL versions + """ + target_expr = _convert_to_expression(dialect, expr) + return core.FunctionCall(dialect, "FLOOR", target_expr) + + +def trunc( + dialect: "SQLDialectBase", + expr: Union[str, "bases.BaseExpression"], + precision: int = 0, +) -> "core.FunctionCall": + """ + Creates a TRUNC function call. + + Returns the value truncated to the specified number of decimal places. + + Usage: + - trunc(dialect, 3.14) -> TRUNC(3.14) + - trunc(dialect, 3.14, 2) -> TRUNC(3.14, 2) + - trunc(dialect, Column("price"), 2) -> TRUNC("price", 2) + + Args: + dialect: The SQL dialect instance + expr: The numeric expression to truncate + precision: Number of decimal places (default 0) + + Returns: + A FunctionCall instance representing the TRUNC function + + Version: All PostgreSQL versions + """ + target_expr = _convert_to_expression(dialect, expr) + precision_expr = core.Literal(dialect, precision) + return core.FunctionCall(dialect, "TRUNC", target_expr, precision_expr) + + +def max_( + dialect: "SQLDialectBase", + expr1: Union[str, "bases.BaseExpression"], + expr2: Union[str, "bases.BaseExpression"], + *more: Union[str, "bases.BaseExpression"], +) -> "core.FunctionCall": + """ + Creates a GREATEST or MAX function call. + + Returns the maximum value among the arguments. + + Usage: + - max_(dialect, 1, 2) -> GREATEST(1, 2) + - max_(dialect, Column("a"), Column("b")) -> GREATEST("a", "b") + + Note: + PostgreSQL's GREATEST and LEAST are scalar functions. + For aggregate MAX/MIN, use the single-argument form. + + Args: + dialect: The SQL dialect instance + expr1: First expression + expr2: Second expression + *more: Additional expressions + + Returns: + A FunctionCall instance representing GREATEST + + Version: All PostgreSQL versions + """ + exprs = [_convert_to_expression(dialect, expr1), _convert_to_expression(dialect, expr2)] + for m in more: + exprs.append(_convert_to_expression(dialect, m)) + return core.FunctionCall(dialect, "GREATEST", *exprs) + + +def min_( + dialect: "SQLDialectBase", + expr1: Union[str, "bases.BaseExpression"], + expr2: Union[str, "bases.BaseExpression"], + *more: Union[str, "bases.BaseExpression"], +) -> "core.FunctionCall": + """ + Creates a LEAST or MIN function call. + + Returns the minimum value among the arguments. + + Usage: + - min_(dialect, 1, 2) -> LEAST(1, 2) + - min_(dialect, Column("a"), Column("b")) -> LEAST("a", "b") + + Note: + PostgreSQL's GREATEST and LEAST are scalar functions. + For aggregate MIN/MAX, use the single-argument form. + + Args: + dialect: The SQL dialect instance + expr1: First expression + expr2: Second expression + *more: Additional expressions + + Returns: + A FunctionCall instance representing LEAST + + Version: All PostgreSQL versions + """ + exprs = [_convert_to_expression(dialect, expr1), _convert_to_expression(dialect, expr2)] + for m in more: + exprs.append(_convert_to_expression(dialect, m)) + return core.FunctionCall(dialect, "LEAST", *exprs) + + +def avg( + dialect: "SQLDialectBase", + expr: Union[str, "bases.BaseExpression"], +) -> "core.FunctionCall": + """ + Creates an AVG aggregate function call. + + Returns the average value of all non-NULL values in a group. + + Usage: + - avg(dialect, Column("price")) -> AVG("price") + + Args: + dialect: The SQL dialect instance + expr: The expression to average + + Returns: + A FunctionCall instance representing the AVG function + + Version: All PostgreSQL versions + """ + target_expr = _convert_to_expression(dialect, expr) + return core.FunctionCall(dialect, "AVG", target_expr) + + +__all__ = [ + "round_", + "pow", + "power", + "sqrt", + "mod", + "ceil", + "floor", + "trunc", + "max_", + "min_", + "avg", +] \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_math_enhanced_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_math_enhanced_functions.py new file mode 100644 index 00000000..62df9656 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_math_enhanced_functions.py @@ -0,0 +1,258 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_math_enhanced_functions.py +""" +Tests for PostgreSQL-specific enhanced math functions. + +These include additional mathematical functions beyond the basic math module. +""" +from rhosocial.activerecord.backend.expression import Column +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.functions.math_enhanced import ( + round_, + pow, + power, + sqrt, + mod, + ceil, + floor, + trunc, + max_, + min_, + avg, +) + + +class TestPostgresMathEnhancedFunctions: + """Tests for PostgreSQL enhanced math functions.""" + + def test_round__default(self, postgres_dialect: PostgresDialect): + """Test round_() with default precision.""" + result = round_(postgres_dialect, Column(postgres_dialect, "value")) + sql, _ = result.to_sql() + assert "ROUND(" in sql + assert '"value"' in sql + + def test_round__with_precision(self, postgres_dialect: PostgresDialect): + """Test round_() with precision.""" + result = round_(postgres_dialect, Column(postgres_dialect, "price"), 2) + sql, _ = result.to_sql() + assert "ROUND(" in sql + + def test_round__with_literal(self, postgres_dialect: PostgresDialect): + """Test round_() with literal value.""" + result = round_(postgres_dialect, 3.14159, 2) + sql, _ = result.to_sql() + assert "ROUND(" in sql + + def test_pow(self, postgres_dialect: PostgresDialect): + """Test pow() function.""" + result = pow(postgres_dialect, Column(postgres_dialect, "base"), 2) + sql, _ = result.to_sql() + assert "POW(" in sql + + def test_pow_both_columns(self, postgres_dialect: PostgresDialect): + """Test pow() with both column references.""" + result = pow( + postgres_dialect, + Column(postgres_dialect, "x"), + Column(postgres_dialect, "y") + ) + sql, _ = result.to_sql() + assert "POW(" in sql + + def test_power(self, postgres_dialect: PostgresDialect): + """Test power() function (alias for POW).""" + result = power(postgres_dialect, 2, 3) + sql, _ = result.to_sql() + assert "POWER(" in sql + + def test_sqrt(self, postgres_dialect: PostgresDialect): + """Test sqrt() function.""" + result = sqrt(postgres_dialect, Column(postgres_dialect, "value")) + sql, _ = result.to_sql() + assert "SQRT(" in sql + assert '"value"' in sql + + def test_sqrt_with_literal(self, postgres_dialect: PostgresDialect): + """Test sqrt() with literal value.""" + result = sqrt(postgres_dialect, 16) + sql, _ = result.to_sql() + assert "SQRT(" in sql + + def test_mod(self, postgres_dialect: PostgresDialect): + """Test mod() function.""" + result = mod(postgres_dialect, Column(postgres_dialect, "total"), 10) + sql, _ = result.to_sql() + assert "MOD(" in sql + + def test_mod_both_columns(self, postgres_dialect: PostgresDialect): + """Test mod() with both column references.""" + result = mod( + postgres_dialect, + Column(postgres_dialect, "dividend"), + Column(postgres_dialect, "divisor") + ) + sql, _ = result.to_sql() + assert "MOD(" in sql + + def test_ceil(self, postgres_dialect: PostgresDialect): + """Test ceil() function.""" + result = ceil(postgres_dialect, Column(postgres_dialect, "value")) + sql, _ = result.to_sql() + assert "CEIL(" in sql + assert '"value"' in sql + + def test_ceil_with_literal(self, postgres_dialect: PostgresDialect): + """Test ceil() with literal value.""" + result = ceil(postgres_dialect, 3.14) + sql, _ = result.to_sql() + assert "CEIL(" in sql + + def test_floor(self, postgres_dialect: PostgresDialect): + """Test floor() function.""" + result = floor(postgres_dialect, Column(postgres_dialect, "value")) + sql, _ = result.to_sql() + assert "FLOOR(" in sql + assert '"value"' in sql + + def test_floor_with_literal(self, postgres_dialect: PostgresDialect): + """Test floor() with literal value.""" + result = floor(postgres_dialect, 3.14) + sql, _ = result.to_sql() + assert "FLOOR(" in sql + + def test_trunc(self, postgres_dialect: PostgresDialect): + """Test trunc() function.""" + result = trunc(postgres_dialect, Column(postgres_dialect, "value")) + sql, _ = result.to_sql() + assert "TRUNC(" in sql + assert '"value"' in sql + + def test_trunc_with_literal(self, postgres_dialect: PostgresDialect): + """Test trunc() with literal value.""" + result = trunc(postgres_dialect, 3.14) + sql, _ = result.to_sql() + assert "TRUNC(" in sql + + def test_trunc_with_precision(self, postgres_dialect: PostgresDialect): + """Test trunc() with precision.""" + result = trunc(postgres_dialect, 3.14159, 2) + sql, _ = result.to_sql() + assert "TRUNC(" in sql + + def test_max__two_args(self, postgres_dialect: PostgresDialect): + """Test max_() with two arguments (uses GREATEST).""" + result = max_(postgres_dialect, Column(postgres_dialect, "a"), Column(postgres_dialect, "b")) + sql, _ = result.to_sql() + assert "GREATEST(" in sql + + def test_max__multiple_args(self, postgres_dialect: PostgresDialect): + """Test max_() with multiple arguments (uses GREATEST).""" + result = max_( + postgres_dialect, + Column(postgres_dialect, "a"), + Column(postgres_dialect, "b"), + Column(postgres_dialect, "c") + ) + sql, _ = result.to_sql() + assert "GREATEST(" in sql + + def test_max__with_literals(self, postgres_dialect: PostgresDialect): + """Test max_() with literal values (uses GREATEST).""" + result = max_(postgres_dialect, 1, 2, 3) + sql, _ = result.to_sql() + assert "GREATEST(" in sql + + def test_min__two_args(self, postgres_dialect: PostgresDialect): + """Test min_() with two arguments (uses LEAST).""" + result = min_(postgres_dialect, Column(postgres_dialect, "a"), Column(postgres_dialect, "b")) + sql, _ = result.to_sql() + assert "LEAST(" in sql + + def test_min__multiple_args(self, postgres_dialect: PostgresDialect): + """Test min_() with multiple arguments (uses LEAST).""" + result = min_( + postgres_dialect, + Column(postgres_dialect, "a"), + Column(postgres_dialect, "b"), + Column(postgres_dialect, "c") + ) + sql, _ = result.to_sql() + assert "LEAST(" in sql + + def test_min__with_literals(self, postgres_dialect: PostgresDialect): + """Test min_() with literal values (uses LEAST).""" + result = min_(postgres_dialect, 1, 2, 3) + sql, _ = result.to_sql() + assert "LEAST(" in sql + + def test_avg(self, postgres_dialect: PostgresDialect): + """Test avg() aggregate function.""" + result = avg(postgres_dialect, Column(postgres_dialect, "price")) + sql, _ = result.to_sql() + assert "AVG(" in sql + assert '"price"' in sql + + def test_avg_with_literal(self, postgres_dialect: PostgresDialect): + """Test avg() with literal value.""" + result = avg(postgres_dialect, 100) + sql, _ = result.to_sql() + assert "AVG(" in sql + + def test_round__with_string_integer(self, postgres_dialect: PostgresDialect): + """Test round_() with string integer value.""" + result = round_(postgres_dialect, "123", 2) + sql, _ = result.to_sql() + assert "ROUND(" in sql + + def test_round__with_string_float(self, postgres_dialect: PostgresDialect): + """Test round_() with string float value.""" + result = round_(postgres_dialect, "3.14159", 2) + sql, _ = result.to_sql() + assert "ROUND(" in sql + + def test_round__with_string_column_name(self, postgres_dialect: PostgresDialect): + """Test round_() with non-numeric string treated as column.""" + result = round_(postgres_dialect, "column_name", 2) + sql, _ = result.to_sql() + assert "ROUND(" in sql + assert '"column_name"' in sql + + def test_pow_with_string_integer(self, postgres_dialect: PostgresDialect): + """Test pow() with string integer exponent.""" + result = pow(postgres_dialect, Column(postgres_dialect, "base"), "2") + sql, _ = result.to_sql() + assert "POW(" in sql + + def test_sqrt_with_string_integer(self, postgres_dialect: PostgresDialect): + """Test sqrt() with string integer value.""" + result = sqrt(postgres_dialect, "16") + sql, _ = result.to_sql() + assert "SQRT(" in sql + + def test_mod_with_string_divisor(self, postgres_dialect: PostgresDialect): + """Test mod() with string divisor.""" + result = mod(postgres_dialect, Column(postgres_dialect, "total"), "10") + sql, _ = result.to_sql() + assert "MOD(" in sql + + def test_max__with_string_literals(self, postgres_dialect: PostgresDialect): + """Test max_() with non-numeric string values (treated as columns in GREATEST).""" + result = max_(postgres_dialect, "a", "b", "c") + sql, _ = result.to_sql() + assert "GREATEST(" in sql + # Non-numeric strings should be treated as column names and quoted + assert '"a"' in sql + + def test_min__with_string_literals(self, postgres_dialect: PostgresDialect): + """Test min_() with non-numeric string values (treated as columns in LEAST).""" + result = min_(postgres_dialect, "a", "b", "c") + sql, _ = result.to_sql() + assert "LEAST(" in sql + # Non-numeric strings should be treated as column names and quoted + assert '"a"' in sql + + def test_avg_with_string_literal(self, postgres_dialect: PostgresDialect): + """Test avg() with string numeric value.""" + result = avg(postgres_dialect, "100") + sql, _ = result.to_sql() + assert "AVG(" in sql \ No newline at end of file