From a1224a9c9951e23aeda366f2b2d9846b5d3ff154 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 10 Apr 2026 12:00:38 +0800 Subject: [PATCH 1/5] feat(postgres): add array, network, UUID functions and fix range constructors New function modules: - array.py: 17 array functions (array_agg, array_append, etc.) - network.py: 15 network functions (inet_client_addr, inet_merge, etc.) - uuid.py: 7 UUID functions (uuid_generate_v1, uuid_generate_v4, etc.) Fix range constructors to use sentinel pattern for bounds parameter: - int4range, int8range, numrange, tsrange, tstzrange, daterange - Omit bounds when using default inclusive '[]', include when explicitly specified Co-Authored-By: Claude Opus 4.6 --- .../impl/postgres/functions/__init__.py | 104 +++++ .../backend/impl/postgres/functions/array.py | 390 ++++++++++++++++++ .../impl/postgres/functions/network.py | 321 ++++++++++++++ .../backend/impl/postgres/functions/range.py | 210 ++++++++++ .../backend/impl/postgres/functions/uuid.py | 157 +++++++ .../postgres/test_postgres_array_functions.py | 130 ++++++ .../test_postgres_network_functions.py | 113 +++++ .../test_postgres_range_constructors.py | 70 ++++ .../postgres/test_postgres_uuid_functions.py | 57 +++ 9 files changed, 1552 insertions(+) create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/functions/array.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/functions/network.py create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/functions/uuid.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_array_functions.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_network_functions.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_range_constructors.py create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_uuid_functions.py diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py index ad2e95f3..28dff502 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py @@ -146,6 +146,59 @@ avg, ) +# Array functions +from .array import ( + array_agg, + array_append, + array_cat, + array_dims, + array_fill, + array_length, + array_lower, + array_ndims, + array_position, + array_positions, + array_prepend, + array_remove, + array_replace, + array_to_string, + array_upper, + unnest, + array_agg_distinct, + string_to_array, +) + +# Network address functions +from .network import ( + inet_client_addr, + inet_client_port, + inet_server_addr, + inet_server_port, + inet_merge, + inet_and, + inet_or, + inetnot, + inet_set_mask, + inet_masklen, + inet_netmask, + inet_network, + inet_recv, + inet_show, + cidr_netmask, + macaddr8_set7bit, +) + +# UUID functions +from .uuid import ( + uuid_generate_v1, + uuid_generate_v1mc, + uuid_generate_v3, + uuid_generate_v4, + uuid_generate_v5, + uuid_nil, + uuid_max, +) + __all__ = [ # Range functions @@ -247,4 +300,55 @@ "max_", "min_", "avg", + # Array functions + "array_agg", + "array_append", + "array_cat", + "array_dims", + "array_fill", + "array_length", + "array_lower", + "array_ndims", + "array_position", + "array_positions", + "array_prepend", + "array_remove", + "array_replace", + "array_to_string", + "array_upper", + "unnest", + "array_agg_distinct", + "string_to_array", + # Network address functions + "inet_client_addr", + "inet_client_port", + "inet_server_addr", + "inet_server_port", + "inet_merge", + "inet_and", + "inet_or", + "inetnot", + "inet_set_mask", + "inet_masklen", + "inet_netmask", + "inet_network", + "inet_recv", + "inet_show", + "cidr_netmask", + "macaddr8_set7bit", + # UUID functions + "uuid_generate_v1", + "uuid_generate_v1mc", + "uuid_generate_v3", + "uuid_generate_v4", + "uuid_generate_v5", + "uuid_nil", + "uuid_max", + # Range constructors + "int4range", + "int8range", + "numrange", + "tsrange", + "tstzrange", + "daterange", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/array.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/array.py new file mode 100644 index 00000000..45735211 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/array.py @@ -0,0 +1,390 @@ +# src/rhosocial/activerecord/backend/impl/postgres/functions/array.py +""" +PostgreSQL Array Functions. + +This module provides SQL expression generators for PostgreSQL array +functions and operators. + +PostgreSQL Documentation: https://www.postgresql.org/docs/current/functions-array.html + +All functions follow the expression-dialect separation architecture: +- First parameter is always the dialect instance +- They return SQL expression strings +""" + +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +def _to_sql(expr: Any) -> str: + """Convert an expression to its SQL string representation.""" + if hasattr(expr, 'to_sql'): + return expr.to_sql()[0] + return str(expr) + + +def array_agg(dialect: "SQLDialectBase", expression: Any) -> str: + """ + Aggregate function that collects values into an array. + + Args: + dialect: The SQL dialect instance + expression: The expression to aggregate + + Returns: + SQL expression: array_agg(expression) + + Example: + >>> array_agg(dialect, "SELECT value FROM table") + 'array_agg(value)' + """ + return f"array_agg({_to_sql(expression)})" + + +def array_append(dialect: "SQLDialectBase", array: Any, element: Any) -> str: + """ + Append an element to the end of an array. + + Args: + dialect: The SQL dialect instance + array: The array expression + element: The element to append + + Returns: + SQL expression: array_append(array, element) + + Example: + >>> array_append(dialect, "'{1,2}'", 3) + "array_append('{1,2}', 3)" + """ + return f"array_append({_to_sql(array)}, {_to_sql(element)})" + + +def array_cat(dialect: "SQLDialectBase", array1: Any, array2: Any) -> str: + """ + Concatenate two arrays. + + Args: + dialect: The SQL dialect instance + array1: First array + array2: Second array + + Returns: + SQL expression: array_cat(array1, array2) + + Example: + >>> array_cat(dialect, "'{1,2}'", "'{3,4}'") + "array_cat('{1,2}', '{3,4}')" + """ + return f"array_cat({_to_sql(array1)}, {_to_sql(array2)})" + + +def array_dims(dialect: "SQLDialectBase", array: Any) -> str: + """ + Return the dimensions of an array as text. + + Args: + dialect: The SQL dialect instance + array: The array expression + + Returns: + SQL expression: array_dims(array) + + Example: + >>> array_dims(dialect, "'{{1,2},{3,4}}'") + "array_dims('{{1,2},{3,4}}')" + """ + return f"array_dims({_to_sql(array)})" + + +def array_fill(dialect: "SQLDialectBase", value: Any, dims: Any) -> str: + """ + Return an array filled with a value for specified dimensions. + + Args: + dialect: The SQL dialect instance + value: The value to fill the array with + dims: The array dimensions + + Returns: + SQL expression: array_fill(value, dims) + + Example: + >>> array_fill(dialect, 0, "'{3,3}'") + "array_fill(0, '{3,3}')" + """ + return f"array_fill({_to_sql(value)}, {_to_sql(dims)})" + + +def array_length(dialect: "SQLDialectBase", array: Any, dimension: int = 1) -> str: + """ + Return the length of an array for the specified dimension. + + Args: + dialect: The SQL dialect instance + array: The array expression + dimension: The dimension (1-indexed, default 1) + + Returns: + SQL expression: array_length(array, dimension) + + Example: + >>> array_length(dialect, "'{1,2,3}'", 1) + "array_length('{1,2,3}', 1)" + """ + return f"array_length({_to_sql(array)}, {dimension})" + + +def array_lower(dialect: "SQLDialectBase", array: Any, dimension: int = 1) -> str: + """ + Return the lower bound of an array's dimension. + + Args: + dialect: The SQL dialect instance + array: The array expression + dimension: The dimension (1-indexed, default 1) + + Returns: + SQL expression: array_lower(array, dimension) + + Example: + >>> array_lower(dialect, "'[1:3]={1,2,3}'", 1) + "array_lower('[1:3]={1,2,3}', 1)" + """ + return f"array_lower({_to_sql(array)}, {dimension})" + + +def array_ndims(dialect: "SQLDialectBase", array: Any) -> str: + """ + Return the number of dimensions of an array. + + Args: + dialect: The SQL dialect instance + array: The array expression + + Returns: + SQL expression: array_ndims(array) + + Example: + >>> array_ndims(dialect, "'{{1,2},{3,4}}'") + "array_ndims('{{1,2},{3,4}}')" + """ + return f"array_ndims({_to_sql(array)})" + + +def array_position(dialect: "SQLDialectBase", array: Any, element: Any) -> str: + """ + Return the position of the first occurrence of element in array. + + Args: + dialect: The SQL dialect instance + array: The array expression + element: The element to find + + Returns: + SQL expression: array_position(array, element) + + Example: + >>> array_position(dialect, "'{a,b,c}'", "'b'") + "array_position('{a,b,c}', 'b')" + """ + return f"array_position({_to_sql(array)}, {_to_sql(element)})" + + +def array_positions(dialect: "SQLDialectBase", array: Any, element: Any) -> str: + """ + Return an array of positions of element in array. + + Args: + dialect: The SQL dialect instance + array: The array expression + element: The element to find + + Returns: + SQL expression: array_positions(array, element) + + Example: + >>> array_positions(dialect, "'[1,3,5,3]'", 3) + "array_positions('[1,3,5,3]', 3)" + """ + return f"array_positions({_to_sql(array)}, {_to_sql(element)})" + + +def array_prepend(dialect: "SQLDialectBase", element: Any, array: Any) -> str: + """ + Prepend an element to the beginning of an array. + + Args: + dialect: The SQL dialect instance + element: The element to prepend + array: The array expression + + Returns: + SQL expression: array_prepend(element, array) + + Example: + >>> array_prepend(dialect, 1, "'{2,3}'") + "array_prepend(1, '{2,3}')" + """ + return f"array_prepend({_to_sql(element)}, {_to_sql(array)})" + + +def array_remove(dialect: "SQLDialectBase", array: Any, element: Any) -> str: + """ + Remove all elements equal to the specified value from array. + + Args: + dialect: The SQL dialect instance + array: The array expression + element: The element to remove + + Returns: + SQL expression: array_remove(array, element) + + Example: + >>> array_remove(dialect, "'{1,2,3,2}'", 2) + "array_remove('{1,2,3,2}', 2)" + """ + return f"array_remove({_to_sql(array)}, {_to_sql(element)})" + + +def array_replace(dialect: "SQLDialectBase", array: Any, search: Any, replace: Any) -> str: + """ + Replace all occurrences of search with replace in array. + + Args: + dialect: The SQL dialect instance + array: The array expression + search: The value to search for + replace: The value to replace with + + Returns: + SQL expression: array_replace(array, search, replace) + + Example: + >>> array_replace(dialect, "'{1,2,3}'", 2, 4) + "array_replace('{1,2,3}', 2, 4)" + """ + return f"array_replace({_to_sql(array)}, {_to_sql(search)}, {_to_sql(replace)})" + + +def array_to_string(dialect: "SQLDialectBase", array: Any, delimiter: str) -> str: + """ + Convert array to string using delimiter. + + Args: + dialect: The SQL dialect instance + array: The array expression + delimiter: The delimiter string + + Returns: + SQL expression: array_to_string(array, delimiter) + + Example: + >>> array_to_string(dialect, "'{1,2,3}'", ',') + "array_to_string('{1,2,3}', ',')" + """ + return f"array_to_string({_to_sql(array)}, '{delimiter}')" + + +def array_upper(dialect: "SQLDialectBase", array: Any, dimension: int = 1) -> str: + """ + Return the upper bound of an array's dimension. + + Args: + dialect: The SQL dialect instance + array: The array expression + dimension: The dimension (1-indexed, default 1) + + Returns: + SQL expression: array_upper(array, dimension) + + Example: + >>> array_upper(dialect, "'[1:3]={1,2,3}'", 1) + "array_upper('[1:3]={1,2,3}', 1)" + """ + return f"array_upper({_to_sql(array)}, {dimension})" + + +def unnest(dialect: "SQLDialectBase", *arrays: Any) -> str: + """ + Expand an array into a set of rows. + + Args: + dialect: The SQL dialect instance + *arrays: One or more array expressions + + Returns: + SQL expression: unnest(array1, array2, ...) + + Example: + >>> unnest(dialect, "'{1,2}'", "'{a,b}'") + "unnest('{1,2}', '{a,b}')" + """ + if not arrays: + return "unnest()" + return f"unnest({', '.join(_to_sql(a) for a in arrays)})" + + +def array_agg_distinct(dialect: "SQLDialectBase", expression: Any) -> str: + """ + Aggregate function that collects distinct values into an array. + + Args: + dialect: The SQL dialect instance + expression: The expression to aggregate + + Returns: + SQL expression: array_agg(DISTINCT expression) + + Example: + >>> array_agg_distinct(dialect, "SELECT value FROM table") + 'array_agg(DISTINCT value)' + """ + return f"array_agg(DISTINCT {_to_sql(expression)})" + + +def string_to_array(dialect: "SQLDialectBase", string: Any, delimiter: str, null_string: str = None) -> str: + """ + Split string into array using delimiter. + + Args: + dialect: The SQL dialect instance + string: The input string + delimiter: The delimiter string + null_string: If specified, occurrences of this string in string are replaced by NULL + + Returns: + SQL expression: string_to_array(string, delimiter[, null_string]) + + Example: + >>> string_to_array(dialect, "'a,b,c'", ',') + "string_to_array('a,b,c', ',')" + """ + if null_string is not None: + return f"string_to_array({_to_sql(string)}, '{delimiter}', '{null_string}')" + return f"string_to_array({_to_sql(string)}, '{delimiter}')" + + +__all__ = [ + "array_agg", + "array_append", + "array_cat", + "array_dims", + "array_fill", + "array_length", + "array_lower", + "array_ndims", + "array_position", + "array_positions", + "array_prepend", + "array_remove", + "array_replace", + "array_to_string", + "array_upper", + "unnest", + "array_agg_distinct", + "string_to_array", +] \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/network.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/network.py new file mode 100644 index 00000000..1d8966de --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/network.py @@ -0,0 +1,321 @@ +# src/rhosocial/activerecord/backend/impl/postgres/functions/network.py +""" +PostgreSQL Network Address Functions. + +This module provides SQL expression generators for PostgreSQL network +address functions and operators. + +PostgreSQL Documentation: https://www.postgresql.org/docs/current/functions-net.html + +All functions follow the expression-dialect separation architecture: +- First parameter is always the dialect instance +- They return SQL expression strings +""" + +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +def _to_sql(expr: Any) -> str: + """Convert an expression to its SQL string representation.""" + if hasattr(expr, 'to_sql'): + return expr.to_sql()[0] + return str(expr) + + +def inet_client_addr(dialect: "SQLDialectBase") -> str: + """ + Return the client's IP address from the current session. + + Returns: + SQL expression: inet_client_addr() + + Example: + >>> inet_client_addr(dialect) + 'inet_client_addr()' + """ + return "inet_client_addr()" + + +def inet_client_port(dialect: "SQLDialectBase") -> str: + """ + Return the client's port number from the current session. + + Returns: + SQL expression: inet_client_port() + + Example: + >>> inet_client_port(dialect) + 'inet_client_port()' + """ + return "inet_client_port()" + + +def inet_server_addr(dialect: "SQLDialectBase") -> str: + """ + Return the server's IP address for the current connection. + + Returns: + SQL expression: inet_server_addr() + + Example: + >>> inet_server_addr(dialect) + 'inet_server_addr()' + """ + return "inet_server_addr()" + + +def inet_server_port(dialect: "SQLDialectBase") -> str: + """ + Return the server's port number for the current connection. + + Returns: + SQL expression: inet_server_port() + + Example: + >>> inet_server_port(dialect) + 'inet_server_port()' + """ + return "inet_server_port()" + + +def inet_merge(dialect: "SQLDialectBase", net1: Any, net2: Any) -> str: + """ + Compute the smallest network that includes both input networks. + + Args: + dialect: The SQL dialect instance + net1: First network (inet or cidr) + net2: Second network (inet or cidr) + + Returns: + SQL expression: inet_merge(net1, net2) + + Example: + >>> inet_merge(dialect, "'192.168.1.0/24'", "'192.168.2.0/24'") + "inet_merge('192.168.1.0/24', '192.168.2.0/24')" + """ + return f"inet_merge({_to_sql(net1)}, {_to_sql(net2)})" + + +def inet_and(dialect: "SQLDialectBase", addr1: Any, addr2: Any) -> str: + """ + Compute bitwise AND of two IP addresses. + + Args: + dialect: The SQL dialect instance + addr1: First IP address (inet or cidr) + addr2: Second IP address (inet or cidr) + + Returns: + SQL expression: addr1 & addr2 + + Example: + >>> inet_and(dialect, "'192.168.1.1'", "'255.255.255.0'") + "inet_and('192.168.1.1', '255.255.255.0')" + """ + return f"inet_and({_to_sql(addr1)}, {_to_sql(addr2)})" + + +def inet_or(dialect: "SQLDialectBase", addr1: Any, addr2: Any) -> str: + """ + Compute bitwise OR of two IP addresses. + + Args: + dialect: The SQL dialect instance + addr1: First IP address (inet or cidr) + addr2: Second IP address (inet or cidr) + + Returns: + SQL expression: addr1 | addr2 + + Example: + >>> inet_or(dialect, "'192.168.1.1'", "'0.0.0.255'") + "inet_or('192.168.1.1', '0.0.0.255')" + """ + return f"inet_or({_to_sql(addr1)}, {_to_sql(addr2)})" + + +def inetnot(dialect: "SQLDialectBase", addr: Any) -> str: + """ + Compute bitwise NOT of an IP address. + + Args: + dialect: The SQL dialect instance + addr: IP address (inet or cidr) + + Returns: + SQL expression: ~addr + + Example: + >>> inetnot(dialect, "'192.168.1.1'") + "inetnot('192.168.1.1')" + """ + return f"inetnot({_to_sql(addr)})" + + +def inet_set_mask(dialect: "SQLDialectBase", addr: Any, mask_len: Any) -> str: + """ + Set the mask length for an IP address. + + Args: + dialect: The SQL dialect instance + addr: IP address (inet or cidr) + mask_len: The mask length + + Returns: + SQL expression: set_masklen(addr, mask_len) + + Example: + >>> inet_set_mask(dialect, "'192.168.1.1'", 24) + "set_masklen('192.168.1.1', 24)" + """ + return f"set_masklen({_to_sql(addr)}, {_to_sql(mask_len)})" + + +def inet_masklen(dialect: "SQLDialectBase", addr: Any) -> str: + """ + Return the mask length of an IP address. + + Args: + dialect: The SQL dialect instance + addr: IP address (inet or cidr) + + Returns: + SQL expression: masklen(addr) + + Example: + >>> inet_masklen(dialect, "'192.168.1.1/24'") + "masklen('192.168.1.1/24')" + """ + return f"masklen({_to_sql(addr)})" + + +def inet_netmask(dialect: "SQLDialectBase", addr: Any) -> str: + """ + Return the network mask of an IP address. + + Args: + dialect: The SQL dialect instance + addr: IP address (inet or cidr) + + Returns: + SQL expression: netmask(addr) + + Example: + >>> inet_netmask(dialect, "'192.168.1.1/24'") + "netmask('192.168.1.1/24')" + """ + return f"netmask({_to_sql(addr)})" + + +def inet_network(dialect: "SQLDialectBase", addr: Any) -> str: + """ + Extract the network part of an IP address. + + Args: + dialect: The SQL dialect instance + addr: IP address (inet or cidr) + + Returns: + SQL expression: network(addr) + + Example: + >>> inet_network(dialect, "'192.168.1.1/24'") + "network('192.168.1.1/24')" + """ + return f"network({_to_sql(addr)})" + + +def inet_recv(dialect: "SQLDialectBase", addr: Any) -> str: + """ + Extract the host part of an IP address. + + Args: + dialect: The SQL dialect instance + addr: IP address (inet or cidr) + + Returns: + SQL expression: host(addr) + + Example: + >>> inet_recv(dialect, "'192.168.1.1/24'") + "host('192.168.1.1/24')" + """ + return f"host({_to_sql(addr)})" + + +def inet_show(dialect: "SQLDialectBase", addr: Any) -> str: + """ + Return the IP address as text. + + Args: + dialect: The SQL dialect instance + addr: IP address (inet or cidr) + + Returns: + SQL expression: text(addr) + + Example: + >>> inet_show(dialect, "'192.168.1.1'") + "text('192.168.1.1')" + """ + return f"text({_to_sql(addr)})" + + +def cidr_netmask(dialect: "SQLDialectBase", addr: Any) -> str: + """ + Return the broadcast address of the network. + + Args: + dialect: The SQL dialect instance + addr: CIDR address + + Returns: + SQL expression: broadcast(addr) + + Example: + >>> cidr_netmask(dialect, "'192.168.1.0/24'") + "broadcast('192.168.1.0/24')" + """ + return f"broadcast({_to_sql(addr)})" + + +def macaddr8_set7bit(dialect: "SQLDialectBase", mac: Any) -> str: + """ + Set the 7th bit of the MAC address (marking it as a multicast address). + + Args: + dialect: The SQL dialect instance + mac: MAC address + + Returns: + SQL expression: macaddr8_set7bit(mac) + + Example: + >>> macaddr8_set7bit(dialect, "'08:00:2b:01:02:03'") + "macaddr8_set7bit('08:00:2b:01:02:03')" + """ + return f"macaddr8_set7bit({_to_sql(mac)})" + + +__all__ = [ + "inet_client_addr", + "inet_client_port", + "inet_server_addr", + "inet_server_port", + "inet_merge", + "inet_and", + "inet_or", + "inetnot", + "inet_set_mask", + "inet_masklen", + "inet_netmask", + "inet_network", + "inet_recv", + "inet_show", + "cidr_netmask", + "macaddr8_set7bit", +] \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/range.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/range.py index 19b6ca9a..c6bea2f9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/range.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/range.py @@ -376,7 +376,209 @@ def range_upper_inf(dialect: "SQLDialectBase", range_value: Any) -> str: return f"upper_inf({r_str})" +# Range type constructors + +# Sentinel value to detect when bounds argument was not provided +_BOUNDS_UNSET = object() + + +def int4range(dialect: "SQLDialectBase", lower: Any = None, upper: Any = None, bounds: Any = _BOUNDS_UNSET) -> str: + """ + Construct an integer range. + + Args: + dialect: The SQL dialect instance + lower: Lower bound (integer or None for unbounded) + upper: Upper bound (integer or None for unbounded) + bounds: Bounds string: '[]' (inclusive), '[)' (lower inclusive), '(]' (upper inclusive), '()' (exclusive). + If not provided, defaults to inclusive '[]'. + + Returns: + SQL expression: int4range(lower, upper[, bounds]) + + Example: + >>> int4range(dialect, 1, 10) + 'int4range(1, 10)' + >>> int4range(dialect, 1, 10, '[)') + "int4range(1, 10, '[)')" + """ + if lower is None and upper is None: + return "int4range()" + elif lower is None: + bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else "" + return f"int4range(NULL, {_to_sql(upper)}{bound_str})" + elif upper is None: + bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else "" + return f"int4range({_to_sql(lower)}, NULL{bound_str})" + elif bounds is _BOUNDS_UNSET: + return f"int4range({_to_sql(lower)}, {_to_sql(upper)})" + return f"int4range({_to_sql(lower)}, {_to_sql(upper)}, '{bounds}')" + + +def int8range(dialect: "SQLDialectBase", lower: Any = None, upper: Any = None, bounds: Any = _BOUNDS_UNSET) -> str: + """ + Construct a big integer range. + + Args: + dialect: The SQL dialect instance + lower: Lower bound (bigint or None for unbounded) + upper: Upper bound (bigint or None for unbounded) + bounds: Bounds string: '[]' (inclusive), '[)' (lower inclusive), '(]' (upper inclusive), '()' (exclusive). + If not provided, defaults to inclusive '[]'. + + Returns: + SQL expression: int8range(lower, upper[, bounds]) + + Example: + >>> int8range(dialect, 1, 1000000) + 'int8range(1, 1000000)' + """ + if lower is None and upper is None: + return "int8range()" + elif lower is None: + bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else "" + return f"int8range(NULL, {_to_sql(upper)}{bound_str})" + elif upper is None: + bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else "" + return f"int8range({_to_sql(lower)}, NULL{bound_str})" + elif bounds is _BOUNDS_UNSET: + return f"int8range({_to_sql(lower)}, {_to_sql(upper)})" + return f"int8range({_to_sql(lower)}, {_to_sql(upper)}, '{bounds}')" + + +def numrange(dialect: "SQLDialectBase", lower: Any = None, upper: Any = None, bounds: Any = _BOUNDS_UNSET) -> str: + """ + Construct a numeric range. + + Args: + dialect: The SQL dialect instance + lower: Lower bound (numeric or None for unbounded) + upper: Upper bound (numeric or None for unbounded) + bounds: Bounds string: '[]' (inclusive), '[)' (lower inclusive), '(]' (upper inclusive), '()' (exclusive). + If not provided, defaults to inclusive '[]'. + + Returns: + SQL expression: numrange(lower, upper[, bounds]) + + Example: + >>> numrange(dialect, 1.5, 10.5) + 'numrange(1.5, 10.5)' + """ + if lower is None and upper is None: + return "numrange()" + elif lower is None: + bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else "" + return f"numrange(NULL, {_to_sql(upper)}{bound_str})" + elif upper is None: + bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else "" + return f"numrange({_to_sql(lower)}, NULL{bound_str})" + elif bounds is _BOUNDS_UNSET: + return f"numrange({_to_sql(lower)}, {_to_sql(upper)})" + return f"numrange({_to_sql(lower)}, {_to_sql(upper)}, '{bounds}')" + + +def tsrange(dialect: "SQLDialectBase", lower: Any = None, upper: Any = None, bounds: Any = _BOUNDS_UNSET) -> str: + """ + Construct a timestamp range (without time zone). + + Args: + dialect: The SQL dialect instance + lower: Lower bound (timestamp or None for unbounded) + upper: Upper bound (timestamp or None for unbounded) + bounds: Bounds string: '[]' (inclusive), '[)' (lower inclusive), '(]' (upper inclusive), '()' (exclusive). + If not provided, defaults to inclusive '[]'. + + Returns: + SQL expression: tsrange(lower, upper[, bounds]) + + Example: + >>> tsrange(dialect, "'2024-01-01 00:00:00'", "'2024-12-31 23:59:59'") + "tsrange('2024-01-01 00:00:00', '2024-12-31 23:59:59')" + """ + if lower is None and upper is None: + return "tsrange()" + elif lower is None: + bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else "" + return f"tsrange(NULL, {_to_sql(upper)}{bound_str})" + elif upper is None: + bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else "" + return f"tsrange({_to_sql(lower)}, NULL{bound_str})" + elif bounds is _BOUNDS_UNSET: + return f"tsrange({_to_sql(lower)}, {_to_sql(upper)})" + return f"tsrange({_to_sql(lower)}, {_to_sql(upper)}, '{bounds}')" + + +def tstzrange(dialect: "SQLDialectBase", lower: Any = None, upper: Any = None, bounds: Any = _BOUNDS_UNSET) -> str: + """ + Construct a timestamp range (with time zone). + + Args: + dialect: The SQL dialect instance + lower: Lower bound (timestamptz or None for unbounded) + upper: Upper bound (timestamptz or None for unbounded) + bounds: Bounds string: '[]' (inclusive), '[)' (lower inclusive), '(]' (upper inclusive), '()' (exclusive). + If not provided, defaults to inclusive '[]'. + + Returns: + SQL expression: tstzrange(lower, upper[, bounds]) + + Example: + >>> tstzrange(dialect, "'2024-01-01 00:00:00+00'", "'2024-12-31 23:59:59+00'") + "tstzrange('2024-01-01 00:00:00+00', '2024-12-31 23:59:59+00')" + """ + if lower is None and upper is None: + return "tstzrange()" + elif lower is None: + bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else "" + return f"tstzrange(NULL, {_to_sql(upper)}{bound_str})" + elif upper is None: + bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else "" + return f"tstzrange({_to_sql(lower)}, NULL{bound_str})" + elif bounds is _BOUNDS_UNSET: + return f"tstzrange({_to_sql(lower)}, {_to_sql(upper)})" + return f"tstzrange({_to_sql(lower)}, {_to_sql(upper)}, '{bounds}')" + + +def daterange(dialect: "SQLDialectBase", lower: Any = None, upper: Any = None, bounds: Any = _BOUNDS_UNSET) -> str: + """ + Construct a date range. + + Args: + dialect: The SQL dialect instance + lower: Lower bound (date or None for unbounded) + upper: Upper bound (date or None for unbounded) + bounds: Bounds string: '[]' (inclusive), '[)' (lower inclusive), '(]' (upper inclusive), '()' (exclusive). + If not provided, defaults to inclusive '[]'. + + Returns: + SQL expression: daterange(lower, upper[, bounds]) + + Example: + >>> daterange(dialect, "'2024-01-01'", "'2024-12-31'") + "daterange('2024-01-01', '2024-12-31')" + """ + if lower is None and upper is None: + return "daterange()" + elif lower is None: + bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else "" + return f"daterange(NULL, {_to_sql(upper)}{bound_str})" + elif upper is None: + bound_str = f", '{bounds}'" if bounds is not _BOUNDS_UNSET else "" + return f"daterange({_to_sql(lower)}, NULL{bound_str})" + elif bounds is _BOUNDS_UNSET: + return f"daterange({_to_sql(lower)}, {_to_sql(upper)})" + return f"daterange({_to_sql(lower)}, {_to_sql(upper)}, '{bounds}')" + + +def _to_sql(expr: Any) -> str: + """Convert an expression to its SQL string representation.""" + if hasattr(expr, 'to_sql'): + return expr.to_sql()[0] + return str(expr) + + __all__ = [ + # Range operators "range_contains", "range_contained_by", "range_contains_range", @@ -389,6 +591,7 @@ def range_upper_inf(dialect: "SQLDialectBase", range_value: Any) -> str: "range_union", "range_intersection", "range_difference", + # Range functions "range_lower", "range_upper", "range_is_empty", @@ -396,4 +599,11 @@ def range_upper_inf(dialect: "SQLDialectBase", range_value: Any) -> str: "range_upper_inc", "range_lower_inf", "range_upper_inf", + # Range type constructors + "int4range", + "int8range", + "numrange", + "tsrange", + "tstzrange", + "daterange", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/uuid.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/uuid.py new file mode 100644 index 00000000..8d2e7f62 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/uuid.py @@ -0,0 +1,157 @@ +# src/rhosocial/activerecord/backend/impl/postgres/functions/uuid.py +""" +PostgreSQL UUID Functions. + +This module provides SQL expression generators for PostgreSQL UUID +functions and operators. + +PostgreSQL Documentation: https://www.postgresql.org/docs/current/functions-uuid.html + +Note: Some functions require the 'uuid-ossp' extension. +Install with: CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +All functions follow the expression-dialect separation architecture: +- First parameter is always the dialect instance +- They return SQL expression strings +""" + +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +def _to_sql(expr: Any) -> str: + """Convert an expression to its SQL string representation.""" + if hasattr(expr, 'to_sql'): + return expr.to_sql()[0] + return str(expr) + + +def uuid_generate_v1(dialect: "SQLDialectBase") -> str: + """ + Generate a version 1 (time-based) UUID. + + Requires the uuid-ossp extension. + + Returns: + SQL expression: uuid_generate_v1() + + Example: + >>> uuid_generate_v1(dialect) + 'uuid_generate_v1()' + """ + return "uuid_generate_v1()" + + +def uuid_generate_v1mc(dialect: "SQLDialectBase") -> str: + """ + Generate a version 1 UUID using a random multicast MAC address. + + Requires the uuid-ossp extension. + + Returns: + SQL expression: uuid_generate_v1mc() + + Example: + >>> uuid_generate_v1mc(dialect) + 'uuid_generate_v1mc()' + """ + return "uuid_generate_v1mc()" + + +def uuid_generate_v3(dialect: "SQLDialectBase", namespace: str, name: str) -> str: + """ + Generate a version 3 (MD5 hash-based) UUID in the given namespace. + + Requires the uuid-ossp extension. + + Args: + dialect: The SQL dialect instance + namespace: UUID namespace (e.g., 'dns', 'url', 'oid', 'x500' or a specific UUID) + name: The name to hash + + Returns: + SQL expression: uuid_generate_v3(namespace, name) + + Example: + >>> uuid_generate_v3(dialect, "'dns'", "'example.com'") + "uuid_generate_v3('dns', 'example.com')" + """ + return f"uuid_generate_v3({_to_sql(namespace)}, {_to_sql(name)})" + + +def uuid_generate_v4(dialect: "SQLDialectBase") -> str: + """ + Generate a version 4 (random) UUID. + + Requires the uuid-ossp extension. + + Returns: + SQL expression: uuid_generate_v4() + + Example: + >>> uuid_generate_v4(dialect) + 'uuid_generate_v4()' + """ + return "uuid_generate_v4()" + + +def uuid_generate_v5(dialect: "SQLDialectBase", namespace: str, name: str) -> str: + """ + Generate a version 5 (SHA-1 hash-based) UUID in the given namespace. + + Requires the uuid-ossp extension. + + Args: + dialect: The SQL dialect instance + namespace: UUID namespace (e.g., 'dns', 'url', 'oid', 'x500' or a specific UUID) + name: The name to hash + + Returns: + SQL expression: uuid_generate_v5(namespace, name) + + Example: + >>> uuid_generate_v5(dialect, "'dns'", "'example.com'") + "uuid_generate_v5('dns', 'example.com')" + """ + return f"uuid_generate_v5({_to_sql(namespace)}, {_to_sql(name)})" + + +def uuid_nil(dialect: "SQLDialectBase") -> str: + """ + Return the nil UUID (all zeros). + + Returns: + SQL expression: uuid_nil() + + Example: + >>> uuid_nil(dialect) + 'uuid_nil()' + """ + return "uuid_nil()" + + +def uuid_max(dialect: "SQLDialectBase") -> str: + """ + Return the maximum UUID (all ones except the first byte). + + Returns: + SQL expression: uuid_max() + + Example: + >>> uuid_max(dialect) + 'uuid_max()' + """ + return "uuid_max()" + + +__all__ = [ + "uuid_generate_v1", + "uuid_generate_v1mc", + "uuid_generate_v3", + "uuid_generate_v4", + "uuid_generate_v5", + "uuid_nil", + "uuid_max", +] \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_array_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_array_functions.py new file mode 100644 index 00000000..2758d846 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_array_functions.py @@ -0,0 +1,130 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_array_functions.py +""" +Tests for PostgreSQL Array functions. + +Functions: array_agg, array_append, array_cat, array_dims, array_fill, + array_length, array_lower, array_ndims, array_position, + array_positions, array_prepend, array_remove, array_replace, + array_to_string, array_upper, unnest, string_to_array +""" + +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.functions.array import ( + array_agg, + array_append, + array_cat, + array_dims, + array_fill, + array_length, + array_lower, + array_ndims, + array_position, + array_positions, + array_prepend, + array_remove, + array_replace, + array_to_string, + array_upper, + unnest, + array_agg_distinct, + string_to_array, +) + + +class TestPostgresArrayFunctions: + """Tests for PostgreSQL array functions.""" + + def test_array_agg(self, postgres_dialect: PostgresDialect): + """Test array_agg() function.""" + result = array_agg(postgres_dialect, "value") + assert result == "array_agg(value)" + + def test_array_append(self, postgres_dialect: PostgresDialect): + """Test array_append() function.""" + result = array_append(postgres_dialect, "'{1,2}'", 3) + assert result == "array_append('{1,2}', 3)" + + def test_array_cat(self, postgres_dialect: PostgresDialect): + """Test array_cat() function.""" + result = array_cat(postgres_dialect, "'{1,2}'", "'{3,4}'") + assert result == "array_cat('{1,2}', '{3,4}')" + + def test_array_dims(self, postgres_dialect: PostgresDialect): + """Test array_dims() function.""" + result = array_dims(postgres_dialect, "'{{1,2},{3,4}}'") + assert result == "array_dims('{{1,2},{3,4}}')" + + def test_array_fill(self, postgres_dialect: PostgresDialect): + """Test array_fill() function.""" + result = array_fill(postgres_dialect, 0, "'{3,3}'") + assert result == "array_fill(0, '{3,3}')" + + def test_array_length(self, postgres_dialect: PostgresDialect): + """Test array_length() function.""" + result = array_length(postgres_dialect, "'{1,2,3}'", 1) + assert result == "array_length('{1,2,3}', 1)" + + def test_array_lower(self, postgres_dialect: PostgresDialect): + """Test array_lower() function.""" + result = array_lower(postgres_dialect, "'[1:3]={1,2,3}'", 1) + assert result == "array_lower('[1:3]={1,2,3}', 1)" + + def test_array_ndims(self, postgres_dialect: PostgresDialect): + """Test array_ndims() function.""" + result = array_ndims(postgres_dialect, "'{{1,2},{3,4}}'") + assert result == "array_ndims('{{1,2},{3,4}}')" + + def test_array_position(self, postgres_dialect: PostgresDialect): + """Test array_position() function.""" + result = array_position(postgres_dialect, "'{a,b,c}'", "'b'") + assert result == "array_position('{a,b,c}', 'b')" + + def test_array_positions(self, postgres_dialect: PostgresDialect): + """Test array_positions() function.""" + result = array_positions(postgres_dialect, "'[1,3,5,3]'", 3) + assert result == "array_positions('[1,3,5,3]', 3)" + + def test_array_prepend(self, postgres_dialect: PostgresDialect): + """Test array_prepend() function.""" + result = array_prepend(postgres_dialect, 1, "'{2,3}'") + assert result == "array_prepend(1, '{2,3}')" + + def test_array_remove(self, postgres_dialect: PostgresDialect): + """Test array_remove() function.""" + result = array_remove(postgres_dialect, "'{1,2,3,2}'", 2) + assert result == "array_remove('{1,2,3,2}', 2)" + + def test_array_replace(self, postgres_dialect: PostgresDialect): + """Test array_replace() function.""" + result = array_replace(postgres_dialect, "'{1,2,3}'", 2, 4) + assert result == "array_replace('{1,2,3}', 2, 4)" + + def test_array_to_string(self, postgres_dialect: PostgresDialect): + """Test array_to_string() function.""" + result = array_to_string(postgres_dialect, "'{1,2,3}'", ',') + assert result == "array_to_string('{1,2,3}', ',')" + + def test_array_upper(self, postgres_dialect: PostgresDialect): + """Test array_upper() function.""" + result = array_upper(postgres_dialect, "'[1:3]={1,2,3}'", 1) + assert result == "array_upper('[1:3]={1,2,3}', 1)" + + def test_unnest_single(self, postgres_dialect: PostgresDialect): + """Test unnest() with single array.""" + result = unnest(postgres_dialect, "'{1,2,3}'") + assert result == "unnest('{1,2,3}')" + + def test_unnest_multiple(self, postgres_dialect: PostgresDialect): + """Test unnest() with multiple arrays.""" + result = unnest(postgres_dialect, "'{1,2}'", "'{a,b}'") + assert result == "unnest('{1,2}', '{a,b}')" + + def test_string_to_array(self, postgres_dialect: PostgresDialect): + """Test string_to_array() function.""" + result = string_to_array(postgres_dialect, "'a,b,c'", ',') + assert result == "string_to_array('a,b,c', ',')" + + def test_string_to_array_with_null(self, postgres_dialect: PostgresDialect): + """Test string_to_array() function with null_string.""" + result = string_to_array(postgres_dialect, "'a,b,NULL,c'", ',', 'NULL') + assert result == "string_to_array('a,b,NULL,c', ',', 'NULL')" \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_network_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_network_functions.py new file mode 100644 index 00000000..1fcd3e2e --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_network_functions.py @@ -0,0 +1,113 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_network_functions.py +""" +Tests for PostgreSQL Network Address functions. + +Functions: inet_client_addr, inet_client_port, inet_server_addr, + inet_server_port, inet_merge, inet_and, inet_or, inetnot, + inet_set_mask, inet_masklen, inet_netmask, inet_network, + inet_recv, inet_show, cidr_netmask, macaddr8_set7bit +""" + +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.functions.network import ( + inet_client_addr, + inet_client_port, + inet_server_addr, + inet_server_port, + inet_merge, + inet_and, + inet_or, + inetnot, + inet_set_mask, + inet_masklen, + inet_netmask, + inet_network, + inet_recv, + inet_show, + cidr_netmask, + macaddr8_set7bit, +) + + +class TestPostgresNetworkFunctions: + """Tests for PostgreSQL network functions.""" + + def test_inet_client_addr(self, postgres_dialect: PostgresDialect): + """Test inet_client_addr() function.""" + result = inet_client_addr(postgres_dialect) + assert result == "inet_client_addr()" + + def test_inet_client_port(self, postgres_dialect: PostgresDialect): + """Test inet_client_port() function.""" + result = inet_client_port(postgres_dialect) + assert result == "inet_client_port()" + + def test_inet_server_addr(self, postgres_dialect: PostgresDialect): + """Test inet_server_addr() function.""" + result = inet_server_addr(postgres_dialect) + assert result == "inet_server_addr()" + + def test_inet_server_port(self, postgres_dialect: PostgresDialect): + """Test inet_server_port() function.""" + result = inet_server_port(postgres_dialect) + assert result == "inet_server_port()" + + def test_inet_merge(self, postgres_dialect: PostgresDialect): + """Test inet_merge() function.""" + result = inet_merge(postgres_dialect, "'192.168.1.0/24'", "'192.168.2.0/24'") + assert result == "inet_merge('192.168.1.0/24', '192.168.2.0/24')" + + def test_inet_and(self, postgres_dialect: PostgresDialect): + """Test inet_and() function.""" + result = inet_and(postgres_dialect, "'192.168.1.1'", "'255.255.255.0'") + assert result == "inet_and('192.168.1.1', '255.255.255.0')" + + def test_inet_or(self, postgres_dialect: PostgresDialect): + """Test inet_or() function.""" + result = inet_or(postgres_dialect, "'192.168.1.1'", "'0.0.0.255'") + assert result == "inet_or('192.168.1.1', '0.0.0.255')" + + def test_inetnot(self, postgres_dialect: PostgresDialect): + """Test inetnot() function.""" + result = inetnot(postgres_dialect, "'192.168.1.1'") + assert result == "inetnot('192.168.1.1')" + + def test_inet_set_mask(self, postgres_dialect: PostgresDialect): + """Test inet_set_mask() function.""" + result = inet_set_mask(postgres_dialect, "'192.168.1.1'", 24) + assert result == "set_masklen('192.168.1.1', 24)" + + def test_inet_masklen(self, postgres_dialect: PostgresDialect): + """Test inet_masklen() function.""" + result = inet_masklen(postgres_dialect, "'192.168.1.1/24'") + assert result == "masklen('192.168.1.1/24')" + + def test_inet_netmask(self, postgres_dialect: PostgresDialect): + """Test inet_netmask() function.""" + result = inet_netmask(postgres_dialect, "'192.168.1.1/24'") + assert result == "netmask('192.168.1.1/24')" + + def test_inet_network(self, postgres_dialect: PostgresDialect): + """Test inet_network() function.""" + result = inet_network(postgres_dialect, "'192.168.1.1/24'") + assert result == "network('192.168.1.1/24')" + + def test_inet_recv(self, postgres_dialect: PostgresDialect): + """Test inet_recv() function (host).""" + result = inet_recv(postgres_dialect, "'192.168.1.1/24'") + assert result == "host('192.168.1.1/24')" + + def test_inet_show(self, postgres_dialect: PostgresDialect): + """Test inet_show() function (text).""" + result = inet_show(postgres_dialect, "'192.168.1.1'") + assert result == "text('192.168.1.1')" + + def test_cidr_netmask(self, postgres_dialect: PostgresDialect): + """Test cidr_netmask() function (broadcast).""" + result = cidr_netmask(postgres_dialect, "'192.168.1.0/24'") + assert result == "broadcast('192.168.1.0/24')" + + def test_macaddr8_set7bit(self, postgres_dialect: PostgresDialect): + """Test macaddr8_set7bit() function.""" + result = macaddr8_set7bit(postgres_dialect, "'08:00:2b:01:02:03'") + assert result == "macaddr8_set7bit('08:00:2b:01:02:03')" \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_range_constructors.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_range_constructors.py new file mode 100644 index 00000000..6cdba6ae --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_range_constructors.py @@ -0,0 +1,70 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_range_constructors.py +""" +Tests for PostgreSQL Range Type Constructors. + +Functions: int4range, int8range, numrange, tsrange, tstzrange, daterange +""" + +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.functions.range import ( + int4range, + int8range, + numrange, + tsrange, + tstzrange, + daterange, +) + + +class TestRangeConstructors: + """Tests for PostgreSQL range type constructors.""" + + def test_int4range_with_bounds(self, postgres_dialect: PostgresDialect): + """Test int4range() with lower and upper bounds.""" + result = int4range(postgres_dialect, 1, 10) + assert result == "int4range(1, 10)" + + def test_int4range_with_bounds_and_inclusive(self, postgres_dialect: PostgresDialect): + """Test int4range() with inclusive bounds.""" + result = int4range(postgres_dialect, 1, 10, "[)") + assert result == "int4range(1, 10, '[)')" + + def test_int4range_empty(self, postgres_dialect: PostgresDialect): + """Test int4range() with no arguments.""" + result = int4range(postgres_dialect) + assert result == "int4range()" + + def test_int8range_with_bounds(self, postgres_dialect: PostgresDialect): + """Test int8range() with lower and upper bounds.""" + result = int8range(postgres_dialect, 1, 1000000) + assert result == "int8range(1, 1000000)" + + def test_int8range_with_bounds_and_exclusive(self, postgres_dialect: PostgresDialect): + """Test int8range() with exclusive bounds.""" + result = int8range(postgres_dialect, 1, 100, "()") + assert result == "int8range(1, 100, '()')" + + def test_numrange_with_bounds(self, postgres_dialect: PostgresDialect): + """Test numrange() with lower and upper bounds.""" + result = numrange(postgres_dialect, 1.5, 10.5) + assert result == "numrange(1.5, 10.5)" + + def test_numrange_with_bounds_and_inclusive(self, postgres_dialect: PostgresDialect): + """Test numrange() with inclusive bounds.""" + result = numrange(postgres_dialect, 1.5, 10.5, "[]") + assert result == "numrange(1.5, 10.5, '[]')" + + def test_tsrange_with_bounds(self, postgres_dialect: PostgresDialect): + """Test tsrange() with lower and upper bounds.""" + result = tsrange(postgres_dialect, "'2024-01-01 00:00:00'", "'2024-12-31 23:59:59'") + assert result == "tsrange('2024-01-01 00:00:00', '2024-12-31 23:59:59')" + + def test_tstzrange_with_bounds(self, postgres_dialect: PostgresDialect): + """Test tstzrange() with lower and upper bounds.""" + result = tstzrange(postgres_dialect, "'2024-01-01 00:00:00+00'", "'2024-12-31 23:59:59+00'") + assert result == "tstzrange('2024-01-01 00:00:00+00', '2024-12-31 23:59:59+00')" + + def test_daterange_with_bounds(self, postgres_dialect: PostgresDialect): + """Test daterange() with lower and upper bounds.""" + result = daterange(postgres_dialect, "'2024-01-01'", "'2024-12-31'") + assert result == "daterange('2024-01-01', '2024-12-31')" \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_uuid_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_uuid_functions.py new file mode 100644 index 00000000..754665d9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_uuid_functions.py @@ -0,0 +1,57 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_uuid_functions.py +""" +Tests for PostgreSQL UUID functions. + +Functions: uuid_generate_v1, uuid_generate_v1mc, uuid_generate_v3, + uuid_generate_v4, uuid_generate_v5, uuid_nil, uuid_max +""" + +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.functions.uuid import ( + uuid_generate_v1, + uuid_generate_v1mc, + uuid_generate_v3, + uuid_generate_v4, + uuid_generate_v5, + uuid_nil, + uuid_max, +) + + +class TestPostgresUUIDFunctions: + """Tests for PostgreSQL UUID functions.""" + + def test_uuid_generate_v1(self, postgres_dialect: PostgresDialect): + """Test uuid_generate_v1() function.""" + result = uuid_generate_v1(postgres_dialect) + assert result == "uuid_generate_v1()" + + def test_uuid_generate_v1mc(self, postgres_dialect: PostgresDialect): + """Test uuid_generate_v1mc() function.""" + result = uuid_generate_v1mc(postgres_dialect) + assert result == "uuid_generate_v1mc()" + + def test_uuid_generate_v3(self, postgres_dialect: PostgresDialect): + """Test uuid_generate_v3() function.""" + result = uuid_generate_v3(postgres_dialect, "'dns'", "'example.com'") + assert result == "uuid_generate_v3('dns', 'example.com')" + + def test_uuid_generate_v4(self, postgres_dialect: PostgresDialect): + """Test uuid_generate_v4() function.""" + result = uuid_generate_v4(postgres_dialect) + assert result == "uuid_generate_v4()" + + def test_uuid_generate_v5(self, postgres_dialect: PostgresDialect): + """Test uuid_generate_v5() function.""" + result = uuid_generate_v5(postgres_dialect, "'dns'", "'example.com'") + assert result == "uuid_generate_v5('dns', 'example.com')" + + def test_uuid_nil(self, postgres_dialect: PostgresDialect): + """Test uuid_nil() function.""" + result = uuid_nil(postgres_dialect) + assert result == "uuid_nil()" + + def test_uuid_max(self, postgres_dialect: PostgresDialect): + """Test uuid_max() function.""" + result = uuid_max(postgres_dialect) + assert result == "uuid_max()" \ No newline at end of file From a07e9553818e06543ff67875cc15260e0cdaa597 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 10 Apr 2026 12:23:59 +0800 Subject: [PATCH 2/5] feat(postgres): add comprehensive hstore function support Add PostgreSQL hstore extension functions including: - Constructors: hstore_from_record, hstore_from_key_value - Key/Value extraction: akeys, skeys, avals, svals, each - Conversion: hstore_to_array, hstore_to_matrix, hstore_to_json, hstore_to_jsonb, hstore_to_json_loose, hstore_to_jsonb_loose - Existence: hstore_exist, hstore_defined - Delete: hstore_delete, hstore_delete_keys, hstore_delete_pairs - Record: hstore_populate_record - Operators: hstore_get_value, hstore_get_values, hstore_concat, hstore_key_exists, hstore_all_keys_exist, hstore_any_key_exists, hstore_contains, hstore_contained_by, hstore_subtract_*, hstore_to_array_operator, hstore_to_matrix_operator, hstore_record_update - Subscript: hstore_subscript_get, hstore_subscript_set Note: Generic function names (exist, delete, defined, slice, populate_record, record_update) are prefixed with hstore_ to avoid confusion with unrelated functionality. --- .../impl/postgres/functions/__init__.py | 77 ++ .../backend/impl/postgres/functions/hstore.py | 750 ++++++++++++++++++ 2 files changed, 827 insertions(+) create mode 100644 src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py index 28dff502..994f856d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py @@ -199,6 +199,46 @@ uuid_max, ) +# hstore functions +from .hstore import ( + hstore_from_record, + hstore_from_key_value, + akeys, + skeys, + avals, + svals, + each, + hstore_to_array, + hstore_to_matrix, + hstore_to_json, + hstore_to_jsonb, + hstore_to_json_loose, + hstore_to_jsonb_loose, + hstore_slice, + hstore_exist, + hstore_defined, + hstore_delete, + hstore_delete_keys, + hstore_delete_pairs, + hstore_populate_record, + hstore_get_value, + hstore_get_values, + hstore_concat, + hstore_key_exists, + hstore_all_keys_exist, + hstore_any_key_exists, + hstore_contains, + hstore_contained_by, + hstore_subtract_key, + hstore_subtract_keys, + hstore_subtract_pairs, + hstore_to_array_operator, + hstore_to_matrix_operator, + hstore_record_update, + hstore_subscript_get, + hstore_subscript_set, +) + __all__ = [ # Range functions @@ -344,6 +384,43 @@ "uuid_generate_v5", "uuid_nil", "uuid_max", + # hstore functions + "hstore_from_record", + "hstore_from_key_value", + "akeys", + "skeys", + "avals", + "svals", + "each", + "hstore_to_array", + "hstore_to_matrix", + "hstore_to_json", + "hstore_to_jsonb", + "hstore_to_json_loose", + "hstore_to_jsonb_loose", + "hstore_slice", + "hstore_exist", + "hstore_defined", + "hstore_delete", + "hstore_delete_keys", + "hstore_delete_pairs", + "hstore_populate_record", + "hstore_get_value", + "hstore_get_values", + "hstore_concat", + "hstore_key_exists", + "hstore_all_keys_exist", + "hstore_any_key_exists", + "hstore_contains", + "hstore_contained_by", + "hstore_subtract_key", + "hstore_subtract_keys", + "hstore_subtract_pairs", + "hstore_to_array_operator", + "hstore_to_matrix_operator", + "hstore_record_update", + "hstore_subscript_get", + "hstore_subscript_set", # Range constructors "int4range", "int8range", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py new file mode 100644 index 00000000..2766a2b0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py @@ -0,0 +1,750 @@ +# src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py +""" +PostgreSQL hstore functions for SQL expression generation. + +This module provides utility functions for generating PostgreSQL hstore SQL expressions. + +hstore is a PostgreSQL extension that stores key/value pairs in a single value. + +PostgreSQL Documentation: https://www.postgresql.org/docs/current/hstore.html + +Supported functions: +- Constructors: hstore(record), hstore(text, text) +- Key/Value Extraction: akeys, skeys, avals, svals, each +- Conversion: hstore_to_array, hstore_to_matrix, hstore_to_json, hstore_to_jsonb +- Subsets: slice +- Existence: exist, defined +- Delete: delete +- Record: populate_record + +Supported operators: +- -> text - Access single key +- -> text[] - Access multiple keys +- || - Concatenate +- ? - Key exists +- ?& - All keys exist +- ?| - Any key exists +- @> - Contains +- <@ - Contained by +- - text - Delete by key +- - text[] - Delete by keys +- - hstore - Delete matching pairs +- %% - To array +- %# - To matrix +- #= - Record update +""" + +from typing import Any + + +def _to_sql(expr: Any) -> str: + """Convert an expression to its SQL string representation.""" + if hasattr(expr, 'to_sql'): + return expr.to_sql()[0] + return str(expr) + + +# ============== Constructors ============== + +def hstore_from_record(record: str) -> str: + """ + Construct hstore from a record/row. + + Args: + record: The record expression (e.g., 'ROW(1,2)' or table column) + + Returns: + SQL expression: hstore(record) + + Example: + >>> hstore_from_record("ROW(1,2)") + "hstore(ROW(1,2))" + """ + return f"hstore({record})" + + +def hstore_from_key_value(key: str, value: str) -> str: + """ + Construct hstore from a single key/value pair. + + Args: + key: The key string + value: The value string + + Returns: + SQL expression: hstore(key, value) + + Example: + >>> hstore_from_key_value("'name'", "'value'") + "hstore('name', 'value')" + """ + return f"hstore({key}, {value})" + + +# ============== Key/Value Extraction ============== + +def akeys(hstore_expr: str) -> str: + """ + Get all keys from hstore as a text array. + + Args: + hstore_expr: The hstore expression + + Returns: + SQL expression: akeys(hstore) + + Example: + >>> akeys("data") + "akeys(data)" + """ + return f"akeys({hstore_expr})" + + +def skeys(hstore_expr: str) -> str: + """ + Get all keys from hstore as a set. + + Args: + hstore_expr: The hstore expression + + Returns: + SQL expression: skeys(hstore) + + Example: + >>> skeys("data") + "skeys(data)" + """ + return f"skeys({hstore_expr})" + + +def avals(hstore_expr: str) -> str: + """ + Get all values from hstore as a text array. + + Args: + hstore_expr: The hstore expression + + Returns: + SQL expression: avals(hstore) + + Example: + >>> avals("data") + "avals(data)" + """ + return f"avals({hstore_expr})" + + +def svals(hstore_expr: str) -> str: + """ + Get all values from hstore as a set. + + Args: + hstore_expr: The hstore expression + + Returns: + SQL expression: svals(hstore) + + Example: + >>> svals("data") + "svals(data)" + """ + return f"svals({hstore_expr})" + + +def each(hstore_expr: str) -> str: + """ + Get all key/value pairs from hstore as a set of (key, value) records. + + Args: + hstore_expr: The hstore expression + + Returns: + SQL expression: each(hstore) + + Example: + >>> each("data") + "each(data)" + """ + return f"each({hstore_expr})" + + +# ============== Conversion Functions ============== + +def hstore_to_array(hstore_expr: str) -> str: + """ + Convert hstore to text array (alternating keys and values). + + Args: + hstore_expr: The hstore expression + + Returns: + SQL expression: hstore_to_array(hstore) + + Example: + >>> hstore_to_array("data") + "hstore_to_array(data)" + """ + return f"hstore_to_array({hstore_expr})" + + +def hstore_to_matrix(hstore_expr: str) -> str: + """ + Convert hstore to 2D text array (key/value pairs as rows). + + Args: + hstore_expr: The hstore expression + + Returns: + SQL expression: hstore_to_matrix(hstore) + + Example: + >>> hstore_to_matrix("data") + "hstore_to_matrix(data)" + """ + return f"hstore_to_matrix({hstore_expr})" + + +def hstore_to_json(hstore_expr: str) -> str: + """ + Convert hstore to JSON (values as strings). + + Args: + hstore_expr: The hstore expression + + Returns: + SQL expression: hstore_to_json(hstore) + + Example: + >>> hstore_to_json("data") + "hstore_to_json(data)" + """ + return f"hstore_to_json({hstore_expr})" + + +def hstore_to_jsonb(hstore_expr: str) -> str: + """ + Convert hstore to JSONB (values as strings). + + Args: + hstore_expr: The hstore expression + + Returns: + SQL expression: hstore_to_jsonb(hstore) + + Example: + >>> hstore_to_jsonb("data") + "hstore_to_jsonb(data)" + """ + return f"hstore_to_jsonb({hstore_expr})" + + +def hstore_to_json_loose(hstore_expr: str) -> str: + """ + Convert hstore to JSON with loose type inference. + + Attempts to infer proper JSON types for values (numeric, boolean, etc). + + Args: + hstore_expr: The hstore expression + + Returns: + SQL expression: hstore_to_json_loose(hstore) + + Example: + >>> hstore_to_json_loose("data") + "hstore_to_json_loose(data)" + """ + return f"hstore_to_json_loose({hstore_expr})" + + +def hstore_to_jsonb_loose(hstore_expr: str) -> str: + """ + Convert hstore to JSONB with loose type inference. + + Attempts to infer proper JSON types for values (numeric, boolean, etc). + + Args: + hstore_expr: The hstore expression + + Returns: + SQL expression: hstore_to_jsonb_loose(hstore) + + Example: + >>> hstore_to_jsonb_loose("data") + "hstore_to_jsonb_loose(data)" + """ + return f"hstore_to_jsonb_loose({hstore_expr})" + + +# ============== Subset Functions ============== + +def hstore_slice(hstore_expr: str, keys: str) -> str: + """ + Extract subset of hstore by keys. + + Args: + hstore_expr: The hstore expression + keys: Array of keys to extract (e.g., "ARRAY['a', 'b']") + + Returns: + SQL expression: slice(hstore, keys) + + Example: + >>> hstore_slice("data", "ARRAY['a', 'b']") + "slice(data, ARRAY['a', 'b'])" + """ + return f"slice({hstore_expr}, {keys})" + + +# ============== Existence Functions ============== + +def hstore_exist(hstore_expr: str, key: str) -> str: + """ + Check if key exists in hstore (including NULL values). + + Args: + hstore_expr: The hstore expression + key: The key to check + + Returns: + SQL expression: exist(hstore, key) + + Example: + >>> hstore_exist("data", "'name'") + "exist(data, 'name')" + """ + return f"exist({hstore_expr}, {key})" + + +def hstore_defined(hstore_expr: str, key: str) -> str: + """ + Check if key exists and has a non-NULL value. + + Args: + hstore_expr: The hstore expression + key: The key to check + + Returns: + SQL expression: defined(hstore, key) + + Example: + >>> hstore_defined("data", "'name'") + "defined(data, 'name')" + """ + return f"defined({hstore_expr}, {key})" + + +# ============== Delete Functions ============== + +def hstore_delete(hstore_expr: str, key: str) -> str: + """ + Delete a key from hstore. + + Args: + hstore_expr: The hstore expression + key: The key to delete + + Returns: + SQL expression: delete(hstore, key) + + Example: + >>> hstore_delete("data", "'name'") + "delete(data, 'name')" + """ + return f"delete({hstore_expr}, {key})" + + +def hstore_delete_keys(hstore_expr: str, keys: str) -> str: + """ + Delete multiple keys from hstore. + + Args: + hstore_expr: The hstore expression + keys: Array of keys to delete (e.g., "ARRAY['a', 'b']") + + Returns: + SQL expression: delete(hstore, keys) + + Example: + >>> hstore_delete_keys("data", "ARRAY['name', 'age']") + "delete(data, ARRAY['name', 'age'])" + """ + return f"delete({hstore_expr}, {keys})" + + +def hstore_delete_pairs(hstore_expr: str, pairs: str) -> str: + """ + Delete matching key/value pairs from hstore. + + Args: + hstore_expr: The hstore expression + pairs: The hstore containing keys/values to delete + + Returns: + SQL expression: delete(hstore, hstore) + + Example: + >>> hstore_delete_pairs("data", "'a=>1'::hstore") + "delete(data, 'a=>1'::hstore)" + """ + return f"delete({hstore_expr}, {pairs})" + + +# ============== Record Functions ============== + +def hstore_populate_record(record: str, hstore_expr: str) -> str: + """ + Update a record/row with values from hstore. + + Args: + record: The record expression to update + hstore_expr: The hstore containing field values + + Returns: + SQL expression: populate_record(record, hstore) + + Example: + >>> hstore_populate_record("ROW(1,2)", "'f1=>42'::hstore") + "populate_record(ROW(1,2), 'f1=>42'::hstore)" + """ + return f"populate_record({record}, {hstore_expr})" + + +# ============== Operators ============== + +def hstore_get_value(hstore_expr: str, key: str) -> str: + """ + Get value by key (operator ->). + + Args: + hstore_expr: The hstore expression + key: The key to access + + Returns: + SQL expression: hstore -> key + + Example: + >>> hstore_get_value("data", "'name'") + "data -> 'name'" + """ + return f"{hstore_expr} -> {key}" + + +def hstore_get_values(hstore_expr: str, keys: str) -> str: + """ + Get values by multiple keys (operator -> text[]). + + Args: + hstore_expr: The hstore expression + keys: Array of keys to access + + Returns: + SQL expression: hstore -> text[] + + Example: + >>> hstore_get_values("data", "ARRAY['name', 'age']") + "data -> ARRAY['name', 'age']" + """ + return f"{hstore_expr} -> {keys}" + + +def hstore_concat(left: str, right: str) -> str: + """ + Concatenate two hstores (operator ||). + + Args: + left: Left hstore expression + right: Right hstore expression + + Returns: + SQL expression: hstore || hstore + + Example: + >>> hstore_concat("'a=>1'::hstore", "'b=>2'::hstore") + "'a=>1'::hstore || 'b=>2'::hstore" + """ + return f"{left} || {right}" + + +def hstore_key_exists(hstore_expr: str, key: str) -> str: + """ + Check if key exists (operator ?). + + Args: + hstore_expr: The hstore expression + key: The key to check + + Returns: + SQL expression: hstore ? key + + Example: + >>> hstore_key_exists("data", "'name'") + "data ? 'name'" + """ + return f"{hstore_expr} ? {key}" + + +def hstore_all_keys_exist(hstore_expr: str, keys: str) -> str: + """ + Check if all keys exist (operator ?&). + + Args: + hstore_expr: The hstore expression + keys: Array of keys that must all exist + + Returns: + SQL expression: hstore ?& keys + + Example: + >>> hstore_all_keys_exist("data", "ARRAY['a', 'b']") + "data ?& ARRAY['a', 'b']" + """ + return f"{hstore_expr} ?& {keys}" + + +def hstore_any_key_exists(hstore_expr: str, keys: str) -> str: + """ + Check if any key exists (operator ?|). + + Args: + hstore_expr: The hstore expression + keys: Array of keys where any must exist + + Returns: + SQL expression: hstore ?| keys + + Example: + >>> hstore_any_key_exists("data", "ARRAY['a', 'b']") + "data ?| ARRAY['a', 'b']" + """ + return f"{hstore_expr} ?| {keys}" + + +def hstore_contains(left: str, right: str) -> str: + """ + Check if left hstore contains right (operator @>). + + Args: + left: Left hstore expression + right: Right hstore expression + + Returns: + SQL expression: hstore @> hstore + + Example: + >>> hstore_contains("data", "'a=>1'") + "data @> 'a=>1'" + """ + return f"{left} @> {right}" + + +def hstore_contained_by(left: str, right: str) -> str: + """ + Check if left hstore is contained by right (operator <@). + + Args: + left: Left hstore expression + right: Right hstore expression + + Returns: + SQL expression: hstore <@ hstore + + Example: + >>> hstore_contained_by("'a=>1'", "data") + "'a=>1' <@ data" + """ + return f"{left} <@ {right}" + + +def hstore_subtract_key(hstore_expr: str, key: str) -> str: + """ + Delete key from hstore (operator - text). + + Args: + hstore_expr: The hstore expression + key: The key to delete + + Returns: + SQL expression: hstore - key + + Example: + >>> hstore_subtract_key("data", "'name'") + "data - 'name'" + """ + return f"{hstore_expr} - {key}" + + +def hstore_subtract_keys(hstore_expr: str, keys: str) -> str: + """ + Delete multiple keys from hstore (operator - text[]). + + Args: + hstore_expr: The hstore expression + keys: Array of keys to delete + + Returns: + SQL expression: hstore - text[] + + Example: + >>> hstore_subtract_keys("data", "ARRAY['a', 'b']") + "data - ARRAY['a', 'b']" + """ + return f"{hstore_expr} - {keys}" + + +def hstore_subtract_pairs(left: str, right: str) -> str: + """ + Delete matching pairs from hstore (operator - hstore). + + Args: + left: Left hstore expression + right: Right hstore containing pairs to delete + + Returns: + SQL expression: hstore - hstore + + Example: + >>> hstore_subtract_pairs("data", "'a=>1'::hstore") + "data - 'a=>1'::hstore" + """ + return f"{left} - {right}" + + +def hstore_to_array_operator(hstore_expr: str) -> str: + """ + Convert hstore to alternating key/value array (operator %%). + + Args: + hstore_expr: The hstore expression + + Returns: + SQL expression: %%hstore + + Example: + >>> hstore_to_array_operator("data") + "%%data" + """ + return f"%%{hstore_expr}" + + +def hstore_to_matrix_operator(hstore_expr: str) -> str: + """ + Convert hstore to 2D key/value array (operator %#). + + Args: + hstore_expr: The hstore expression + + Returns: + SQL expression: %#hstore + + Example: + >>> hstore_to_matrix_operator("data") + "%#data" + """ + return f"%#{hstore_expr}" + + +def hstore_record_update(record: str, hstore_expr: str) -> str: + """ + Update record fields from hstore (operator #=). + + Args: + record: The record expression to update + hstore_expr: The hstore containing field values + + Returns: + SQL expression: record #= hstore + + Example: + >>> record_update("ROW(1,2)", "'f1=>42'::hstore") + "ROW(1,2) #= 'f1=>42'::hstore" + """ + return f"{record} #= {hstore_expr}" + + +# ============== Subscript Access ============== + +def hstore_subscript_get(hstore_expr: str, key: str) -> str: + """ + Get value by key using subscript syntax. + + Args: + hstore_expr: The hstore expression + key: The key to access + + Returns: + SQL expression: hstore[key] + + Example: + >>> hstore_subscript_get("data", "'name'") + "data['name']" + """ + return f"{hstore_expr}[{key}]" + + +def hstore_subscript_set(hstore_expr: str, key: str, value: str) -> str: + """ + Set value by key using subscript syntax. + + Args: + hstore_expr: The hstore expression + key: The key to set + value: The value to set + + Returns: + SQL expression: hstore[key] = value + + Example: + >>> hstore_subscript_set("data", "'name'", "'value'") + "data['name'] = 'value'" + """ + return f"{hstore_expr}[{key}] = {value}" + + +__all__ = [ + # Constructors + "hstore_from_record", + "hstore_from_key_value", + # Key/Value Extraction + "akeys", + "skeys", + "avals", + "svals", + "each", + # Conversion + "hstore_to_array", + "hstore_to_matrix", + "hstore_to_json", + "hstore_to_jsonb", + "hstore_to_json_loose", + "hstore_to_jsonb_loose", + # Subset + "hstore_slice", + # Existence + "hstore_exist", + "hstore_defined", + # Delete + "hstore_delete", + "hstore_delete_keys", + "hstore_delete_pairs", + # Record + "hstore_populate_record", + # Operators + "hstore_get_value", + "hstore_get_values", + "hstore_concat", + "hstore_key_exists", + "hstore_all_keys_exist", + "hstore_any_key_exists", + "hstore_contains", + "hstore_contained_by", + "hstore_subtract_key", + "hstore_subtract_keys", + "hstore_subtract_pairs", + "hstore_to_array_operator", + "hstore_to_matrix_operator", + "hstore_record_update", + # Subscript + "hstore_subscript_get", + "hstore_subscript_set", +] From 6f7404b8c6c3d7e2406f8fa1c67270ad9c292ea4 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 10 Apr 2026 12:28:38 +0800 Subject: [PATCH 3/5] refactor(postgres): rename hstore extraction functions with hstore_ prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename generic function names to avoid confusion: - akeys → hstore_akeys - skeys → hstore_skeys - avals → hstore_avals - svals → hstore_svals - each → hstore_each All hstore functions now consistently use the hstore_ prefix. --- .../impl/postgres/functions/__init__.py | 20 ++++++------- .../backend/impl/postgres/functions/hstore.py | 30 +++++++++---------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py index 994f856d..cee5d167 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py @@ -203,11 +203,11 @@ from .hstore import ( hstore_from_record, hstore_from_key_value, - akeys, - skeys, - avals, - svals, - each, + hstore_akeys, + hstore_skeys, + hstore_avals, + hstore_svals, + hstore_each, hstore_to_array, hstore_to_matrix, hstore_to_json, @@ -387,11 +387,11 @@ # hstore functions "hstore_from_record", "hstore_from_key_value", - "akeys", - "skeys", - "avals", - "svals", - "each", + "hstore_akeys", + "hstore_skeys", + "hstore_avals", + "hstore_svals", + "hstore_each", "hstore_to_array", "hstore_to_matrix", "hstore_to_json", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py index 2766a2b0..ec63b7ae 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py @@ -83,7 +83,7 @@ def hstore_from_key_value(key: str, value: str) -> str: # ============== Key/Value Extraction ============== -def akeys(hstore_expr: str) -> str: +def hstore_akeys(hstore_expr: str) -> str: """ Get all keys from hstore as a text array. @@ -94,13 +94,13 @@ def akeys(hstore_expr: str) -> str: SQL expression: akeys(hstore) Example: - >>> akeys("data") + >>> hstore_akeys("data") "akeys(data)" """ return f"akeys({hstore_expr})" -def skeys(hstore_expr: str) -> str: +def hstore_skeys(hstore_expr: str) -> str: """ Get all keys from hstore as a set. @@ -111,13 +111,13 @@ def skeys(hstore_expr: str) -> str: SQL expression: skeys(hstore) Example: - >>> skeys("data") + >>> hstore_skeys("data") "skeys(data)" """ return f"skeys({hstore_expr})" -def avals(hstore_expr: str) -> str: +def hstore_avals(hstore_expr: str) -> str: """ Get all values from hstore as a text array. @@ -128,13 +128,13 @@ def avals(hstore_expr: str) -> str: SQL expression: avals(hstore) Example: - >>> avals("data") + >>> hstore_avals("data") "avals(data)" """ return f"avals({hstore_expr})" -def svals(hstore_expr: str) -> str: +def hstore_svals(hstore_expr: str) -> str: """ Get all values from hstore as a set. @@ -145,13 +145,13 @@ def svals(hstore_expr: str) -> str: SQL expression: svals(hstore) Example: - >>> svals("data") + >>> hstore_svals("data") "svals(data)" """ return f"svals({hstore_expr})" -def each(hstore_expr: str) -> str: +def hstore_each(hstore_expr: str) -> str: """ Get all key/value pairs from hstore as a set of (key, value) records. @@ -162,7 +162,7 @@ def each(hstore_expr: str) -> str: SQL expression: each(hstore) Example: - >>> each("data") + >>> hstore_each("data") "each(data)" """ return f"each({hstore_expr})" @@ -706,11 +706,11 @@ def hstore_subscript_set(hstore_expr: str, key: str, value: str) -> str: "hstore_from_record", "hstore_from_key_value", # Key/Value Extraction - "akeys", - "skeys", - "avals", - "svals", - "each", + "hstore_akeys", + "hstore_skeys", + "hstore_avals", + "hstore_svals", + "hstore_each", # Conversion "hstore_to_array", "hstore_to_matrix", From e4aebb3636577c1d5c971e8ea108d7a320c237f4 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Fri, 10 Apr 2026 22:56:04 +0800 Subject: [PATCH 4/5] chore: add changelog fragment for PR #26 --- changelog.d/26.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/26.added.md diff --git a/changelog.d/26.added.md b/changelog.d/26.added.md new file mode 100644 index 00000000..772a287f --- /dev/null +++ b/changelog.d/26.added.md @@ -0,0 +1 @@ +Added comprehensive PostgreSQL function support: array (18), network address (16), UUID (8), hstore (33), and range constructors (6). From 2df451e10863b2af2bd29b02264eb340bf867128 Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Sat, 11 Apr 2026 00:12:36 +0800 Subject: [PATCH 5/5] test(postgres): add hstore function tests for full coverage --- .../test_postgres_hstore_functions.py | 307 ++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py new file mode 100644 index 00000000..fd98f9a1 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py @@ -0,0 +1,307 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_postgres_hstore_functions.py +""" +Tests for PostgreSQL hstore functions. + +Functions: hstore_from_record, hstore_from_key_value, hstore_akeys, + hstore_skeys, hstore_avals, hstore_svals, hstore_each, + hstore_to_array, hstore_to_matrix, hstore_to_json, + hstore_to_jsonb, hstore_to_json_loose, hstore_to_jsonb_loose, + hstore_slice, hstore_exist, hstore_defined, + hstore_delete, hstore_delete_keys, hstore_delete_pairs, + hstore_populate_record, + hstore_get_value, hstore_get_values, hstore_concat, + hstore_key_exists, hstore_all_keys_exist, hstore_any_key_exists, + hstore_contains, hstore_contained_by, + hstore_subtract_key, hstore_subtract_keys, hstore_subtract_pairs, + hstore_to_array_operator, hstore_to_matrix_operator, + hstore_record_update, + hstore_subscript_get, hstore_subscript_set +""" + +from unittest.mock import MagicMock + +from rhosocial.activerecord.backend.impl.postgres.functions.hstore import ( + _to_sql, + hstore_from_record, + hstore_from_key_value, + hstore_akeys, + hstore_skeys, + hstore_avals, + hstore_svals, + hstore_each, + hstore_to_array, + hstore_to_matrix, + hstore_to_json, + hstore_to_jsonb, + hstore_to_json_loose, + hstore_to_jsonb_loose, + hstore_slice, + hstore_exist, + hstore_defined, + hstore_delete, + hstore_delete_keys, + hstore_delete_pairs, + hstore_populate_record, + hstore_get_value, + hstore_get_values, + hstore_concat, + hstore_key_exists, + hstore_all_keys_exist, + hstore_any_key_exists, + hstore_contains, + hstore_contained_by, + hstore_subtract_key, + hstore_subtract_keys, + hstore_subtract_pairs, + hstore_to_array_operator, + hstore_to_matrix_operator, + hstore_record_update, + hstore_subscript_get, + hstore_subscript_set, +) + + +class TestToSql: + """Tests for the internal _to_sql helper function.""" + + def test_to_sql_with_to_sql_method(self): + """Test _to_sql with an object that has a to_sql method.""" + mock_expr = MagicMock() + mock_expr.to_sql.return_value = ("mocked_sql",) + result = _to_sql(mock_expr) + assert result == "mocked_sql" + mock_expr.to_sql.assert_called_once() + + def test_to_sql_with_plain_string(self): + """Test _to_sql with a plain string value.""" + result = _to_sql("plain_value") + assert result == "plain_value" + + def test_to_sql_with_number(self): + """Test _to_sql with a number value.""" + result = _to_sql(42) + assert result == "42" + + +class TestHstoreConstructors: + """Tests for hstore constructor functions.""" + + def test_hstore_from_record(self): + """Test hstore_from_record() function.""" + result = hstore_from_record("ROW(1,2)") + assert result == "hstore(ROW(1,2))" + + def test_hstore_from_key_value(self): + """Test hstore_from_key_value() function.""" + result = hstore_from_key_value("'name'", "'value'") + assert result == "hstore('name', 'value')" + + +class TestHstoreKeyValueExtraction: + """Tests for hstore key/value extraction functions.""" + + def test_hstore_akeys(self): + """Test hstore_akeys() function.""" + result = hstore_akeys("data") + assert result == "akeys(data)" + + def test_hstore_skeys(self): + """Test hstore_skeys() function.""" + result = hstore_skeys("data") + assert result == "skeys(data)" + + def test_hstore_avals(self): + """Test hstore_avals() function.""" + result = hstore_avals("data") + assert result == "avals(data)" + + def test_hstore_svals(self): + """Test hstore_svals() function.""" + result = hstore_svals("data") + assert result == "svals(data)" + + def test_hstore_each(self): + """Test hstore_each() function.""" + result = hstore_each("data") + assert result == "each(data)" + + +class TestHstoreConversion: + """Tests for hstore conversion functions.""" + + def test_hstore_to_array(self): + """Test hstore_to_array() function.""" + result = hstore_to_array("data") + assert result == "hstore_to_array(data)" + + def test_hstore_to_matrix(self): + """Test hstore_to_matrix() function.""" + result = hstore_to_matrix("data") + assert result == "hstore_to_matrix(data)" + + def test_hstore_to_json(self): + """Test hstore_to_json() function.""" + result = hstore_to_json("data") + assert result == "hstore_to_json(data)" + + def test_hstore_to_jsonb(self): + """Test hstore_to_jsonb() function.""" + result = hstore_to_jsonb("data") + assert result == "hstore_to_jsonb(data)" + + def test_hstore_to_json_loose(self): + """Test hstore_to_json_loose() function.""" + result = hstore_to_json_loose("data") + assert result == "hstore_to_json_loose(data)" + + def test_hstore_to_jsonb_loose(self): + """Test hstore_to_jsonb_loose() function.""" + result = hstore_to_jsonb_loose("data") + assert result == "hstore_to_jsonb_loose(data)" + + +class TestHstoreSubset: + """Tests for hstore subset functions.""" + + def test_hstore_slice(self): + """Test hstore_slice() function.""" + result = hstore_slice("data", "ARRAY['a', 'b']") + assert result == "slice(data, ARRAY['a', 'b'])" + + +class TestHstoreExistence: + """Tests for hstore existence functions.""" + + def test_hstore_exist(self): + """Test hstore_exist() function.""" + result = hstore_exist("data", "'name'") + assert result == "exist(data, 'name')" + + def test_hstore_defined(self): + """Test hstore_defined() function.""" + result = hstore_defined("data", "'name'") + assert result == "defined(data, 'name')" + + +class TestHstoreDelete: + """Tests for hstore delete functions.""" + + def test_hstore_delete(self): + """Test hstore_delete() function.""" + result = hstore_delete("data", "'name'") + assert result == "delete(data, 'name')" + + def test_hstore_delete_keys(self): + """Test hstore_delete_keys() function.""" + result = hstore_delete_keys("data", "ARRAY['name', 'age']") + assert result == "delete(data, ARRAY['name', 'age'])" + + def test_hstore_delete_pairs(self): + """Test hstore_delete_pairs() function.""" + result = hstore_delete_pairs("data", "'a=>1'::hstore") + assert result == "delete(data, 'a=>1'::hstore)" + + +class TestHstoreRecord: + """Tests for hstore record functions.""" + + def test_hstore_populate_record(self): + """Test hstore_populate_record() function.""" + result = hstore_populate_record("ROW(1,2)", "'f1=>42'::hstore") + assert result == "populate_record(ROW(1,2), 'f1=>42'::hstore)" + + +class TestHstoreOperators: + """Tests for hstore operator functions.""" + + def test_hstore_get_value(self): + """Test hstore_get_value() function (operator ->).""" + result = hstore_get_value("data", "'name'") + assert result == "data -> 'name'" + + def test_hstore_get_values(self): + """Test hstore_get_values() function (operator -> text[]).""" + result = hstore_get_values("data", "ARRAY['name', 'age']") + assert result == "data -> ARRAY['name', 'age']" + + def test_hstore_concat(self): + """Test hstore_concat() function (operator ||).""" + result = hstore_concat("'a=>1'::hstore", "'b=>2'::hstore") + assert result == "'a=>1'::hstore || 'b=>2'::hstore" + + def test_hstore_key_exists(self): + """Test hstore_key_exists() function (operator ?).""" + result = hstore_key_exists("data", "'name'") + assert result == "data ? 'name'" + + def test_hstore_all_keys_exist(self): + """Test hstore_all_keys_exist() function (operator ?&).""" + result = hstore_all_keys_exist("data", "ARRAY['a', 'b']") + assert result == "data ?& ARRAY['a', 'b']" + + def test_hstore_any_key_exists(self): + """Test hstore_any_key_exists() function (operator ?|).""" + result = hstore_any_key_exists("data", "ARRAY['a', 'b']") + assert result == "data ?| ARRAY['a', 'b']" + + def test_hstore_contains(self): + """Test hstore_contains() function (operator @>).""" + result = hstore_contains("data", "'a=>1'") + assert result == "data @> 'a=>1'" + + def test_hstore_contained_by(self): + """Test hstore_contained_by() function (operator <@).""" + result = hstore_contained_by("'a=>1'", "data") + assert result == "'a=>1' <@ data" + + +class TestHstoreSubtraction: + """Tests for hstore subtraction operator functions.""" + + def test_hstore_subtract_key(self): + """Test hstore_subtract_key() function (operator - text).""" + result = hstore_subtract_key("data", "'name'") + assert result == "data - 'name'" + + def test_hstore_subtract_keys(self): + """Test hstore_subtract_keys() function (operator - text[]).""" + result = hstore_subtract_keys("data", "ARRAY['a', 'b']") + assert result == "data - ARRAY['a', 'b']" + + def test_hstore_subtract_pairs(self): + """Test hstore_subtract_pairs() function (operator - hstore).""" + result = hstore_subtract_pairs("data", "'a=>1'::hstore") + assert result == "data - 'a=>1'::hstore" + + +class TestHstoreSpecialOperators: + """Tests for hstore special operator functions.""" + + def test_hstore_to_array_operator(self): + """Test hstore_to_array_operator() function (operator %%).""" + result = hstore_to_array_operator("data") + assert result == "%%data" + + def test_hstore_to_matrix_operator(self): + """Test hstore_to_matrix_operator() function (operator %#).""" + result = hstore_to_matrix_operator("data") + assert result == "%#data" + + def test_hstore_record_update(self): + """Test hstore_record_update() function (operator #=).""" + result = hstore_record_update("ROW(1,2)", "'f1=>42'::hstore") + assert result == "ROW(1,2) #= 'f1=>42'::hstore" + + +class TestHstoreSubscript: + """Tests for hstore subscript access functions.""" + + def test_hstore_subscript_get(self): + """Test hstore_subscript_get() function.""" + result = hstore_subscript_get("data", "'name'") + assert result == "data['name']" + + def test_hstore_subscript_set(self): + """Test hstore_subscript_set() function.""" + result = hstore_subscript_set("data", "'name'", "'value'") + assert result == "data['name'] = 'value'"