From 8723ddc9369a030b729d9ed4179cc566dbb67e73 Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Wed, 27 May 2026 13:11:23 +0200 Subject: [PATCH 01/15] Add formula language engine for #108 --- includes/Formula/Compiler.php | 467 +++++++++++++++++++++++++ includes/Formula/Evaluator.php | 215 ++++++++++++ includes/Formula/FormulaError.php | 26 ++ includes/Formula/FormulaEvalError.php | 12 + includes/Formula/FormulaParseError.php | 12 + includes/Formula/Functions.php | 336 ++++++++++++++++++ includes/Formula/Lexer.php | 218 ++++++++++++ includes/Formula/Materializer.php | 201 +++++++++++ includes/Formula/Parser.php | 201 +++++++++++ 9 files changed, 1688 insertions(+) create mode 100644 includes/Formula/Compiler.php create mode 100644 includes/Formula/Evaluator.php create mode 100644 includes/Formula/FormulaError.php create mode 100644 includes/Formula/FormulaEvalError.php create mode 100644 includes/Formula/FormulaParseError.php create mode 100644 includes/Formula/Functions.php create mode 100644 includes/Formula/Lexer.php create mode 100644 includes/Formula/Materializer.php create mode 100644 includes/Formula/Parser.php diff --git a/includes/Formula/Compiler.php b/includes/Formula/Compiler.php new file mode 100644 index 00000000..06a2290a --- /dev/null +++ b/includes/Formula/Compiler.php @@ -0,0 +1,467 @@ + array( + 'key' => 'title', + 'type' => 'text', + ), + 'Created' => array( + 'key' => 'created_at', + 'type' => 'datetime', + ), + 'Last edited' => array( + 'key' => 'modified_at', + 'type' => 'datetime', + ), + ); + + /** + * @return array{ast:array,deps:int[],result_type:string,volatile:bool,refs:array>} + * @throws FormulaParseError When the expression is invalid. + */ + public function compile( string $expression, int $collection_id, int $self_field_id = 0, array $previous_refs = array(), array $formula_overrides = array() ): array { + $tokens = ( new Lexer() )->tokenize( $expression ); + $raw_ast = ( new Parser( $tokens ) )->parse(); + $this->assert_ast_limits( $raw_ast ); + $field_map = $this->collection_field_map( $collection_id, $formula_overrides ); + $resolved = $this->resolve_node( $raw_ast, $field_map, $self_field_id, $previous_refs ); + + $deps = array_values( array_unique( array_map( 'intval', $resolved['deps'] ) ) ); + $this->assert_no_cycle( $collection_id, $self_field_id, $deps ); + + return array( + 'ast' => $resolved['node'], + 'deps' => $deps, + 'result_type' => (string) $resolved['type'], + 'volatile' => ! empty( $resolved['volatile'] ), + 'refs' => $resolved['refs'], + ); + } + + /** + * @param array $formula_overrides Compiled formula metadata not yet persisted. + * @return array + */ + private function collection_field_map( int $collection_id, array $formula_overrides = array() ): array { + $map = array(); + foreach ( get_post_meta( $collection_id, 'fields', false ) as $raw_field_id ) { + $field_id = (int) $raw_field_id; + $field = get_post( $field_id ); + if ( ! $field instanceof WP_Post || Field::POST_TYPE !== $field->post_type ) { + continue; + } + $type = (string) get_post_meta( $field_id, 'type', true ); + $result_type = FieldTypeRegistry::effective_type_for_field( $field_id, $type ); + $volatile = 'formula' === $type && $this->field_is_volatile( $field_id ); + if ( 'formula' === $type && isset( $formula_overrides[ $field_id ] ) ) { + $result_type = (string) ( $formula_overrides[ $field_id ]['result_type'] ?? $result_type ); + $volatile = ! empty( $formula_overrides[ $field_id ]['volatile'] ); + } + $map[ $field_id ] = array( + 'id' => $field_id, + 'title' => $field->post_title, + 'type' => $type, + 'result_type' => $this->formula_result_type_for_reference( $result_type ), + 'multiple' => 'multiselect' === $type || ( 'relation' === $type && Relations::relation_is_multiple( $field_id ) ), + 'volatile' => $volatile, + ); + } + return $map; + } + + private function formula_result_type_for_reference( string $type ): string { + return match ( $type ) { + 'email', 'url', 'select' => 'text', + default => $type, + }; + } + + /** + * @param array $node Raw formula AST. + */ + private function assert_ast_limits( array $node ): void { + $count = 0; + $this->walk_ast_limits( $node, 0, $count ); + } + + /** + * @param array $node Raw formula AST. + */ + private function walk_ast_limits( array $node, int $depth, int &$count ): void { + if ( $depth > self::MAX_AST_DEPTH ) { + throw new FormulaParseError( + 'cortext_formula_too_deep', + __( 'Formula is nested too deeply.', 'cortext' ) + ); + } + + ++$count; + if ( $count > self::MAX_AST_NODES ) { + throw new FormulaParseError( + 'cortext_formula_too_complex', + __( 'Formula is too complex.', 'cortext' ) + ); + } + + foreach ( array( 'argument', 'left', 'right' ) as $child_key ) { + if ( isset( $node[ $child_key ] ) && is_array( $node[ $child_key ] ) ) { + $this->walk_ast_limits( (array) $node[ $child_key ], $depth + 1, $count ); + } + } + + if ( ! isset( $node['args'] ) || ! is_array( $node['args'] ) ) { + return; + } + + foreach ( $node['args'] as $arg ) { + if ( is_array( $arg ) ) { + $this->walk_ast_limits( $arg, $depth + 1, $count ); + } + } + } + + /** + * @param array $node + * @param array $field_map + * @return array{node:array,type:string,deps:int[],volatile:bool,refs:array>} + */ + private function resolve_node( array $node, array $field_map, int $self_field_id, array $previous_refs ): array { + return match ( $node['node'] ?? '' ) { + 'literal' => array( + 'node' => $node, + 'type' => (string) $node['type'], + 'deps' => array(), + 'volatile' => false, + 'refs' => array(), + ), + 'unary' => $this->resolve_unary( $node, $field_map, $self_field_id, $previous_refs ), + 'binary' => $this->resolve_binary( $node, $field_map, $self_field_id, $previous_refs ), + 'call' => $this->resolve_call( $node, $field_map, $self_field_id, $previous_refs ), + default => throw new FormulaParseError( + 'cortext_formula_invalid_ast', + __( 'Formula could not be parsed.', 'cortext' ) + ), + }; + } + + /** + * @param array $node + * @return array{node:array,type:string,deps:int[],volatile:bool,refs:array>} + */ + private function resolve_unary( array $node, array $field_map, int $self_field_id, array $previous_refs ): array { + $arg = $this->resolve_node( (array) $node['argument'], $field_map, $self_field_id, $previous_refs ); + if ( 'number' !== $arg['type'] ) { + throw new FormulaParseError( + 'cortext_formula_type_mismatch', + __( 'Unary minus needs a number.', 'cortext' ) + ); + } + return array( + 'node' => array( + 'node' => 'unary', + 'operator' => '-', + 'argument' => $arg['node'], + 'type' => 'number', + ), + 'type' => 'number', + 'deps' => $arg['deps'], + 'volatile' => $arg['volatile'], + 'refs' => $arg['refs'], + ); + } + + /** + * @param array $node + * @return array{node:array,type:string,deps:int[],volatile:bool,refs:array>} + */ + private function resolve_binary( array $node, array $field_map, int $self_field_id, array $previous_refs ): array { + $left = $this->resolve_node( (array) $node['left'], $field_map, $self_field_id, $previous_refs ); + $right = $this->resolve_node( (array) $node['right'], $field_map, $self_field_id, $previous_refs ); + $operator = (string) $node['operator']; + $type = 'number'; + + if ( in_array( $operator, array( '=', '==', '!=', '>', '<', '>=', '<=' ), true ) ) { + if ( ! $this->comparable_types( $left['type'], $right['type'] ) ) { + throw new FormulaParseError( + 'cortext_formula_type_mismatch', + __( 'Comparison values must have compatible types.', 'cortext' ) + ); + } + $type = 'checkbox'; + } elseif ( '+' === $operator && ( 'text' === $left['type'] || 'text' === $right['type'] ) ) { + $type = 'text'; + } elseif ( 'number' !== $left['type'] || 'number' !== $right['type'] ) { + throw new FormulaParseError( + 'cortext_formula_type_mismatch', + __( 'Math operators need numbers.', 'cortext' ) + ); + } + + return array( + 'node' => array( + 'node' => 'binary', + 'operator' => $operator, + 'left' => $left['node'], + 'right' => $right['node'], + 'type' => $type, + ), + 'type' => $type, + 'deps' => array_merge( $left['deps'], $right['deps'] ), + 'volatile' => $left['volatile'] || $right['volatile'], + 'refs' => array_merge( $left['refs'], $right['refs'] ), + ); + } + + /** + * @param array $node + * @return array{node:array,type:string,deps:int[],volatile:bool,refs:array>} + */ + private function resolve_call( array $node, array $field_map, int $self_field_id, array $previous_refs ): array { + $name = (string) $node['name']; + if ( in_array( $name, array( 'field', 'prop' ), true ) ) { + return $this->resolve_prop( (array) $node['args'], $field_map, $self_field_id, $previous_refs ); + } + + $args = array_map( + fn( array $arg ): array => $this->resolve_node( $arg, $field_map, $self_field_id, $previous_refs ), + (array) $node['args'] + ); + $inferred = Functions::infer( $name, array_column( $args, 'node' ) ); + + return array( + 'node' => array( + 'node' => 'call', + 'name' => $name, + 'args' => array_column( $args, 'node' ), + 'type' => $inferred['type'], + 'volatile' => $inferred['volatile'], + ), + 'type' => $inferred['type'], + 'deps' => $this->merge_child_values( $args, 'deps' ), + 'volatile' => $inferred['volatile'] || in_array( true, array_column( $args, 'volatile' ), true ), + 'refs' => $this->merge_child_values( $args, 'refs' ), + ); + } + + /** + * @param array> $args Resolved child expressions. + * @return array + */ + private function merge_child_values( array $args, string $key ): array { + $values = array(); + foreach ( $args as $arg ) { + if ( isset( $arg[ $key ] ) && is_array( $arg[ $key ] ) ) { + $values = array_merge( $values, $arg[ $key ] ); + } + } + return $values; + } + + /** + * @param array> $args + * @return array{node:array,type:string,deps:int[],volatile:bool,refs:array>} + */ + private function resolve_prop( array $args, array $field_map, int $self_field_id, array $previous_refs ): array { + if ( 1 !== count( $args ) || 'literal' !== ( $args[0]['node'] ?? '' ) || 'text' !== ( $args[0]['type'] ?? '' ) ) { + throw new FormulaParseError( + 'cortext_formula_invalid_prop', + __( 'field() needs one quoted field name. prop() works as an alias.', 'cortext' ) + ); + } + + $name = (string) $args[0]['value']; + if ( isset( self::SYSTEM_PROPS[ $name ] ) ) { + $system = self::SYSTEM_PROPS[ $name ]; + return array( + 'node' => array( + 'node' => 'prop', + 'source' => 'system', + 'key' => $system['key'], + 'name' => $name, + 'type' => $system['type'], + ), + 'type' => $system['type'], + 'deps' => array(), + 'volatile' => false, + 'refs' => array( + $name => array( + 'source' => 'system', + 'key' => $system['key'], + ), + ), + ); + } + + $matches = array(); + if ( isset( $previous_refs[ $name ] ) ) { + $previous = $previous_refs[ $name ]; + $previous_id = isset( $previous['id'] ) ? (int) $previous['id'] : 0; + if ( isset( $field_map[ $previous_id ] ) ) { + $matches = array( $field_map[ $previous_id ] ); + } + } + if ( count( $matches ) === 0 ) { + $matches = array_values( + array_filter( + $field_map, + static fn( array $field ): bool => $field['title'] === $name + ) + ); + } + + if ( count( $matches ) === 0 ) { + throw new FormulaParseError( + 'cortext_formula_unknown_prop', + sprintf( + /* translators: %s: referenced field name. */ + __( 'Unknown field in formula: %s', 'cortext' ), + $name + ) + ); + } + + if ( count( $matches ) > 1 ) { + throw new FormulaParseError( + 'cortext_formula_ambiguous_prop', + sprintf( + /* translators: %s: referenced field name. */ + __( 'More than one field is named %s.', 'cortext' ), + $name + ) + ); + } + + $field = $matches[0]; + $field_id = (int) $field['id']; + if ( $field_id === $self_field_id ) { + throw new FormulaParseError( + 'cortext_formula_self_reference', + __( 'A formula cannot reference itself.', 'cortext' ) + ); + } + if ( $field['multiple'] || in_array( $field['type'], array( 'relation', 'rollup' ), true ) ) { + throw new FormulaParseError( + 'cortext_formula_unsupported_target_type', + __( 'Formulas cannot reference multi-value, relation, or rollup fields in v0.', 'cortext' ) + ); + } + + $volatile = 'formula' === $field['type'] && ! empty( $field['volatile'] ); + return array( + 'node' => array( + 'node' => 'prop', + 'source' => 'field', + 'field_id' => $field_id, + 'name' => $name, + 'type' => $field['result_type'], + ), + 'type' => $field['result_type'], + 'deps' => array( $field_id ), + 'volatile' => $volatile, + 'refs' => array( + $name => array( + 'source' => 'field', + 'id' => $field_id, + ), + ), + ); + } + + private function comparable_types( string $left, string $right ): bool { + if ( $left === $right ) { + return true; + } + return in_array( $left, array( 'date', 'datetime' ), true ) && in_array( $right, array( 'date', 'datetime' ), true ); + } + + /** + * @param int[] $deps + */ + private function assert_no_cycle( int $collection_id, int $self_field_id, array $deps ): void { + if ( $self_field_id < 1 ) { + return; + } + + $graph = array( $self_field_id => $deps ); + foreach ( get_post_meta( $collection_id, 'fields', false ) as $raw_field_id ) { + $field_id = (int) $raw_field_id; + if ( $field_id < 1 || $field_id === $self_field_id || 'formula' !== (string) get_post_meta( $field_id, 'type', true ) ) { + continue; + } + $graph[ $field_id ] = $this->stored_deps( $field_id ); + } + + $visiting = array(); + $visited = array(); + $walk = function ( int $field_id ) use ( &$walk, &$graph, &$visiting, &$visited, $self_field_id ): void { + if ( isset( $visited[ $field_id ] ) ) { + return; + } + if ( isset( $visiting[ $field_id ] ) ) { + throw new FormulaParseError( + 'cortext_formula_cycle', + __( 'Formula references loop back on themselves.', 'cortext' ) + ); + } + $visiting[ $field_id ] = true; + foreach ( $graph[ $field_id ] ?? array() as $dep_id ) { + if ( (int) $dep_id === $self_field_id && $field_id !== $self_field_id ) { + throw new FormulaParseError( + 'cortext_formula_cycle', + __( 'Formula references loop back on themselves.', 'cortext' ) + ); + } + if ( isset( $graph[ (int) $dep_id ] ) ) { + $walk( (int) $dep_id ); + } + } + unset( $visiting[ $field_id ] ); + $visited[ $field_id ] = true; + }; + + $walk( $self_field_id ); + } + + /** + * @return int[] + */ + private function stored_deps( int $field_id ): array { + $raw = (string) get_post_meta( $field_id, 'formula_dep_field_ids', true ); + if ( '' === $raw ) { + return array(); + } + $decoded = json_decode( $raw, true ); + if ( ! is_array( $decoded ) ) { + return array(); + } + return array_values( array_filter( array_map( 'intval', $decoded ) ) ); + } + + private function field_is_volatile( int $field_id ): bool { + $value = get_post_meta( $field_id, 'formula_is_volatile', true ); + return true === $value || '1' === (string) $value; + } +} diff --git a/includes/Formula/Evaluator.php b/includes/Formula/Evaluator.php new file mode 100644 index 00000000..fc94b55b --- /dev/null +++ b/includes/Formula/Evaluator.php @@ -0,0 +1,215 @@ + $ast Compiled formula AST. + * @return array{value:mixed,type:string} + * @throws FormulaEvalError When evaluation fails. + */ + public function evaluate( array $ast, WP_Post $row ): array { + return $this->evaluate_node( $ast, $row ); + } + + /** + * @param array $node + * @return array{value:mixed,type:string} + */ + private function evaluate_node( array $node, WP_Post $row ): array { + return match ( $node['node'] ?? '' ) { + 'literal' => array( + 'value' => $node['value'] ?? null, + 'type' => (string) ( $node['type'] ?? 'text' ), + ), + 'prop' => $this->evaluate_prop( $node, $row ), + 'unary' => $this->evaluate_unary( $node, $row ), + 'binary' => $this->evaluate_binary( $node, $row ), + 'call' => $this->evaluate_call( $node, $row ), + default => throw new FormulaEvalError( + 'cortext_formula_invalid_ast', + __( 'Formula could not be evaluated.', 'cortext' ) + ), + }; + } + + /** + * @param array $node + * @return array{value:mixed,type:string} + */ + private function evaluate_prop( array $node, WP_Post $row ): array { + $type = (string) ( $node['type'] ?? 'text' ); + if ( 'system' === ( $node['source'] ?? '' ) ) { + $key = (string) ( $node['key'] ?? '' ); + return array( + 'value' => match ( $key ) { + 'title' => $row->post_title, + 'created_at' => $this->format_gmt_date( $row->post_date_gmt ), + 'modified_at' => $this->format_gmt_date( $row->post_modified_gmt ), + default => null, + }, + 'type' => $type, + ); + } + + $field_id = (int) ( $node['field_id'] ?? 0 ); + if ( $field_id < 1 ) { + return array( + 'value' => null, + 'type' => $type, + ); + } + return array( + 'value' => $this->typed_field_value( $row->ID, $field_id, $type ), + 'type' => $type, + ); + } + + /** + * @param array $node + * @return array{value:mixed,type:string} + */ + private function evaluate_unary( array $node, WP_Post $row ): array { + $value = $this->evaluate_node( (array) $node['argument'], $row ); + if ( 'number' !== $value['type'] ) { + throw new FormulaEvalError( + 'cortext_formula_type_mismatch', + __( 'Unary minus needs a number.', 'cortext' ) + ); + } + return array( + 'value' => -1 * (float) ( $value['value'] ?? 0 ), + 'type' => 'number', + ); + } + + /** + * @param array $node + * @return array{value:mixed,type:string} + */ + private function evaluate_binary( array $node, WP_Post $row ): array { + $left = $this->evaluate_node( (array) $node['left'], $row ); + $right = $this->evaluate_node( (array) $node['right'], $row ); + $operator = (string) $node['operator']; + + if ( in_array( $operator, array( '=', '==', '!=', '>', '<', '>=', '<=' ), true ) ) { + return array( + 'value' => $this->compare_values( $left, $right, $operator ), + 'type' => 'checkbox', + ); + } + + if ( '+' === $operator && ( 'text' === $left['type'] || 'text' === $right['type'] ) ) { + return array( + 'value' => (string) ( $left['value'] ?? '' ) . (string) ( $right['value'] ?? '' ), + 'type' => 'text', + ); + } + + if ( 'number' !== $left['type'] || 'number' !== $right['type'] ) { + throw new FormulaEvalError( + 'cortext_formula_type_mismatch', + __( 'Math operators need numbers.', 'cortext' ) + ); + } + + $a = (float) ( $left['value'] ?? 0 ); + $b = (float) ( $right['value'] ?? 0 ); + if ( '/' === $operator && 0.0 === $b ) { + throw new FormulaEvalError( + 'cortext_formula_divide_by_zero', + __( 'Cannot divide by zero.', 'cortext' ) + ); + } + + return array( + 'value' => match ( $operator ) { + '+' => $a + $b, + '-' => $a - $b, + '*' => $a * $b, + '/' => $a / $b, + default => null, + }, + 'type' => 'number', + ); + } + + /** + * @param array $node + * @return array{value:mixed,type:string} + */ + private function evaluate_call( array $node, WP_Post $row ): array { + $args = array_map( + fn( array $arg ): array => $this->evaluate_node( $arg, $row ), + (array) $node['args'] + ); + return Functions::evaluate( (string) $node['name'], $args ); + } + + /** + * @param array{value:mixed,type:string} $left + * @param array{value:mixed,type:string} $right + */ + private function compare_values( array $left, array $right, string $operator ): bool { + $a = $left['value'] ?? null; + $b = $right['value'] ?? null; + + if ( in_array( $left['type'], array( 'date', 'datetime' ), true ) && in_array( $right['type'], array( 'date', 'datetime' ), true ) ) { + $a = strtotime( (string) $a ); + $b = strtotime( (string) $b ); + } + + if ( 'number' === $left['type'] && 'number' === $right['type'] ) { + $a = (float) $a; + $b = (float) $b; + } + + return match ( $operator ) { + '=', '==' => $a == $b, // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual + '!=' => $a != $b, // phpcs:ignore Universal.Operators.StrictComparisons.LooseNotEqual + '>' => $a > $b, + '<' => $a < $b, + '>=' => $a >= $b, + '<=' => $a <= $b, + default => false, + }; + } + + private function typed_field_value( int $row_id, int $field_id, string $type ): mixed { + $key = Relations::meta_key( $field_id ); + $value = get_post_meta( $row_id, $key, true ); + if ( '' === $value || null === $value ) { + return 'checkbox' === $type ? false : null; + } + return match ( $type ) { + 'number' => is_numeric( $value ) ? (float) $value : null, + 'checkbox' => Relations::is_truthy( $value ), + 'date' => false === strtotime( (string) $value ) ? null : gmdate( 'Y-m-d', (int) strtotime( (string) $value ) ), + 'datetime' => false === strtotime( (string) $value ) ? null : gmdate( DATE_RFC3339, (int) strtotime( (string) $value ) ), + default => (string) $value, + }; + } + + private function format_gmt_date( ?string $mysql_gmt ): string { + if ( ! $mysql_gmt || '0000-00-00 00:00:00' === $mysql_gmt ) { + return ''; + } + $timestamp = strtotime( $mysql_gmt . ' UTC' ); + return false === $timestamp ? '' : gmdate( DATE_RFC3339, $timestamp ); + } +} diff --git a/includes/Formula/FormulaError.php b/includes/Formula/FormulaError.php new file mode 100644 index 00000000..6c477d67 --- /dev/null +++ b/includes/Formula/FormulaError.php @@ -0,0 +1,26 @@ +formula_code = $formula_code; + } + + public function formula_code(): string { + return $this->formula_code; + } +} diff --git a/includes/Formula/FormulaEvalError.php b/includes/Formula/FormulaEvalError.php new file mode 100644 index 00000000..026dfa65 --- /dev/null +++ b/includes/Formula/FormulaEvalError.php @@ -0,0 +1,12 @@ +> $args Resolved argument AST nodes. + * @return array{type:string,volatile:bool} + * @throws FormulaParseError When function usage is invalid. + */ + public static function infer( string $name, array $args ): array { + $arity = count( $args ); + $volatile = array_reduce( + $args, + static fn( bool $carry, array $arg ): bool => $carry || ! empty( $arg['volatile'] ), + false + ); + + return match ( $name ) { + 'concat' => array( + 'type' => self::require_min_arity( $name, $arity, 1, 'text' ), + 'volatile' => $volatile, + ), + 'length' => array( + 'type' => self::require_types( $name, $args, array( 'text' ), 'number' ), + 'volatile' => $volatile, + ), + 'upper', 'lower' => array( + 'type' => self::require_types( $name, $args, array( 'text' ), 'text' ), + 'volatile' => $volatile, + ), + 'contains' => array( + 'type' => self::require_types( $name, $args, array( 'text', 'text' ), 'checkbox' ), + 'volatile' => $volatile, + ), + 'if' => self::infer_if( $args, $volatile ), + 'now' => array( + 'type' => self::require_types( $name, $args, array(), 'datetime' ), + 'volatile' => true, + ), + 'datebetween' => array( + 'type' => self::require_date_between( $args ), + 'volatile' => $volatile, + ), + 'formatdate' => array( + 'type' => self::require_format_date( $args ), + 'volatile' => $volatile, + ), + default => throw new FormulaParseError( + 'cortext_formula_unknown_function', + sprintf( + /* translators: %s: formula function name. */ + __( 'Unknown formula function: %s', 'cortext' ), + $name + ) + ), + }; + } + + /** + * @param array $args Runtime values. + * @return array{value:mixed,type:string} + * @throws FormulaEvalError When evaluation fails. + */ + public static function evaluate( string $name, array $args ): array { + return match ( $name ) { + 'concat' => array( + 'value' => implode( '', array_map( static fn( array $arg ): string => self::to_text( $arg ), $args ) ), + 'type' => 'text', + ), + 'length' => array( + 'value' => strlen( self::to_text( $args[0] ?? array( 'value' => '' ) ) ), + 'type' => 'number', + ), + 'upper' => array( + 'value' => strtoupper( self::to_text( $args[0] ?? array( 'value' => '' ) ) ), + 'type' => 'text', + ), + 'lower' => array( + 'value' => strtolower( self::to_text( $args[0] ?? array( 'value' => '' ) ) ), + 'type' => 'text', + ), + 'contains' => array( + 'value' => str_contains( + self::to_text( $args[0] ?? array( 'value' => '' ) ), + self::to_text( $args[1] ?? array( 'value' => '' ) ) + ), + 'type' => 'checkbox', + ), + 'if' => self::evaluate_if( $args ), + 'now' => array( + 'value' => gmdate( DATE_RFC3339 ), + 'type' => 'datetime', + ), + 'datebetween' => array( + 'value' => self::date_between( $args ), + 'type' => 'number', + ), + 'formatdate' => array( + 'value' => self::format_date( $args ), + 'type' => 'text', + ), + default => throw new FormulaEvalError( + 'cortext_formula_unknown_function', + __( 'Formula references an unknown function.', 'cortext' ) + ), + }; + } + + private static function require_min_arity( string $name, int $actual, int $minimum, string $return_type ): string { + if ( $actual < $minimum ) { + throw new FormulaParseError( + 'cortext_formula_invalid_arity', + sprintf( + /* translators: 1: function name, 2: minimum argument count. */ + __( '%1$s() needs at least %2$d argument(s).', 'cortext' ), + $name, + $minimum + ) + ); + } + return $return_type; + } + + /** + * @param array> $args + * @param string[] $expected + */ + private static function require_types( string $name, array $args, array $expected, string $return_type ): string { + if ( count( $args ) !== count( $expected ) ) { + throw new FormulaParseError( + 'cortext_formula_invalid_arity', + sprintf( + /* translators: 1: function name, 2: argument count. */ + __( '%1$s() needs %2$d argument(s).', 'cortext' ), + $name, + count( $expected ) + ) + ); + } + + foreach ( $expected as $index => $type ) { + if ( ! self::type_matches( (string) $args[ $index ]['type'], $type ) ) { + throw new FormulaParseError( + 'cortext_formula_type_mismatch', + sprintf( + /* translators: 1: function name, 2: argument number, 3: expected type. */ + __( '%1$s() argument %2$d needs a %3$s value.', 'cortext' ), + $name, + $index + 1, + $type + ) + ); + } + } + + return $return_type; + } + + /** + * @param array> $args + * @return array{type:string,volatile:bool} + */ + private static function infer_if( array $args, bool $volatile ): array { + if ( 3 !== count( $args ) ) { + throw new FormulaParseError( + 'cortext_formula_invalid_arity', + __( 'if() needs 3 arguments.', 'cortext' ) + ); + } + if ( ! self::type_matches( (string) $args[0]['type'], 'checkbox' ) ) { + throw new FormulaParseError( + 'cortext_formula_type_mismatch', + __( 'if() condition must be true or false.', 'cortext' ) + ); + } + if ( $args[1]['type'] !== $args[2]['type'] ) { + throw new FormulaParseError( + 'cortext_formula_mixed_if', + __( 'Both if() branches must return the same type in v0.', 'cortext' ) + ); + } + return array( + 'type' => (string) $args[1]['type'], + 'volatile' => $volatile, + ); + } + + /** + * @param array> $args + */ + private static function require_date_between( array $args ): string { + if ( 3 !== count( $args ) ) { + throw new FormulaParseError( + 'cortext_formula_invalid_arity', + __( 'dateBetween() needs 3 arguments.', 'cortext' ) + ); + } + if ( ! self::type_matches( (string) $args[0]['type'], 'date' ) || ! self::type_matches( (string) $args[1]['type'], 'date' ) ) { + throw new FormulaParseError( + 'cortext_formula_type_mismatch', + __( 'dateBetween() needs two dates.', 'cortext' ) + ); + } + if ( 'text' !== $args[2]['type'] ) { + throw new FormulaParseError( + 'cortext_formula_type_mismatch', + __( 'dateBetween() unit must be text.', 'cortext' ) + ); + } + return 'number'; + } + + /** + * @param array> $args + */ + private static function require_format_date( array $args ): string { + if ( 2 !== count( $args ) ) { + throw new FormulaParseError( + 'cortext_formula_invalid_arity', + __( 'formatDate() needs 2 arguments.', 'cortext' ) + ); + } + if ( ! self::type_matches( (string) $args[0]['type'], 'date' ) || 'text' !== $args[1]['type'] ) { + throw new FormulaParseError( + 'cortext_formula_type_mismatch', + __( 'formatDate() needs a date and a text format.', 'cortext' ) + ); + } + return 'text'; + } + + private static function type_matches( string $actual, string $expected ): bool { + if ( $actual === $expected ) { + return true; + } + return 'date' === $expected && in_array( $actual, array( 'date', 'datetime' ), true ); + } + + /** + * @param array{value:mixed,type?:string} $arg + */ + private static function to_text( array $arg ): string { + $value = $arg['value'] ?? ''; + if ( null === $value ) { + return ''; + } + if ( is_bool( $value ) ) { + return $value ? 'true' : 'false'; + } + return (string) $value; + } + + /** + * @param array $args + * @return array{value:mixed,type:string} + */ + private static function evaluate_if( array $args ): array { + $condition = ! empty( $args[0]['value'] ); + return $condition ? $args[1] : $args[2]; + } + + /** + * @param array $args + */ + private static function date_between( array $args ): ?float { + $a = strtotime( (string) ( $args[0]['value'] ?? '' ) ); + $b = strtotime( (string) ( $args[1]['value'] ?? '' ) ); + if ( false === $a || false === $b ) { + return null; + } + $unit = strtolower( trim( (string) ( $args[2]['value'] ?? 'days' ) ) ); + $seconds = $a - $b; + if ( in_array( $unit, array( 'months', 'month', 'years', 'year' ), true ) ) { + return self::calendar_date_difference( $a, $b, $unit ); + } + return match ( $unit ) { + 'minutes', 'minute' => floor( $seconds / MINUTE_IN_SECONDS ), + 'hours', 'hour' => floor( $seconds / HOUR_IN_SECONDS ), + 'weeks', 'week' => floor( $seconds / WEEK_IN_SECONDS ), + default => floor( $seconds / DAY_IN_SECONDS ), + }; + } + + private static function calendar_date_difference( int $a, int $b, string $unit ): int { + $sign = $a >= $b ? 1 : -1; + $start = ( new \DateTimeImmutable( '@' . min( $a, $b ) ) )->setTimezone( new \DateTimeZone( 'UTC' ) ); + $end = ( new \DateTimeImmutable( '@' . max( $a, $b ) ) )->setTimezone( new \DateTimeZone( 'UTC' ) ); + $diff = $start->diff( $end ); + + $months = ( $diff->y * 12 ) + $diff->m; + if ( in_array( $unit, array( 'years', 'year' ), true ) ) { + return $sign * $diff->y; + } + return $sign * $months; + } + + /** + * @param array $args + */ + private static function format_date( array $args ): string { + $timestamp = strtotime( (string) ( $args[0]['value'] ?? '' ) ); + if ( false === $timestamp ) { + return ''; + } + $format = (string) ( $args[1]['value'] ?? 'YYYY-MM-DD' ); + $php_format = strtr( + $format, + array( + 'YYYY' => 'Y', + 'MMMM' => 'F', + 'MMM' => 'M', + 'MM' => 'm', + 'DD' => 'd', + 'mm' => 'i', + 'h' => 'G', + 'A' => 'A', + 'D' => 'j', + 'Y' => 'Y', + ) + ); + return wp_date( $php_format, $timestamp ); + } +} diff --git a/includes/Formula/Lexer.php b/includes/Formula/Lexer.php new file mode 100644 index 00000000..a61eff98 --- /dev/null +++ b/includes/Formula/Lexer.php @@ -0,0 +1,218 @@ + + * @throws FormulaParseError When the expression contains invalid syntax. + */ + public function tokenize( string $expression ): array { + $tokens = array(); + $length = strlen( $expression ); + $i = 0; + + if ( $length > self::MAX_EXPRESSION_LENGTH ) { + throw new FormulaParseError( + 'cortext_formula_too_long', + __( 'Formula is too long.', 'cortext' ) + ); + } + + while ( $i < $length ) { + $char = $expression[ $i ]; + if ( ctype_space( $char ) ) { + ++$i; + continue; + } + + if ( ctype_digit( $char ) || ( '.' === $char && $i + 1 < $length && ctype_digit( $expression[ $i + 1 ] ) ) ) { + $start = $i; + $seen_dot = false; + while ( $i < $length ) { + $current = $expression[ $i ]; + if ( '.' === $current ) { + if ( $seen_dot ) { + break; + } + $seen_dot = true; + ++$i; + continue; + } + if ( ! ctype_digit( $current ) ) { + break; + } + ++$i; + } + $this->append_token( + $tokens, + array( + 'type' => 'number', + 'value' => (float) substr( $expression, $start, $i - $start ), + 'pos' => $start, + ) + ); + continue; + } + + if ( '"' === $char ) { + $start = $i; + ++$i; + $value = ''; + while ( $i < $length ) { + $current = $expression[ $i ]; + if ( '"' === $current ) { + ++$i; + $this->append_token( + $tokens, + array( + 'type' => 'string', + 'value' => $value, + 'pos' => $start, + ) + ); + continue 2; + } + if ( '\\' === $current ) { + ++$i; + if ( $i >= $length ) { + break; + } + $escaped = $expression[ $i ]; + $value .= match ( $escaped ) { + 'n' => "\n", + 't' => "\t", + '"' => '"', + '\\' => '\\', + default => $escaped, + }; + $this->assert_string_length( $value ); + ++$i; + continue; + } + $value .= $current; + $this->assert_string_length( $value ); + ++$i; + } + throw new FormulaParseError( + 'cortext_formula_unclosed_string', + __( 'Text is missing a closing quote.', 'cortext' ) + ); + } + + if ( ctype_alpha( $char ) || '_' === $char ) { + $start = $i; + while ( $i < $length && ( ctype_alnum( $expression[ $i ] ) || '_' === $expression[ $i ] ) ) { + ++$i; + } + $this->append_token( + $tokens, + array( + 'type' => 'identifier', + 'value' => substr( $expression, $start, $i - $start ), + 'pos' => $start, + ) + ); + continue; + } + + $two = $i + 1 < $length ? substr( $expression, $i, 2 ) : ''; + if ( in_array( $two, array( '==', '!=', '>=', '<=' ), true ) ) { + $this->append_token( + $tokens, + array( + 'type' => 'operator', + 'value' => $two, + 'pos' => $i, + ) + ); + $i += 2; + continue; + } + + if ( in_array( $char, array( '+', '-', '*', '/', '=', '>', '<' ), true ) ) { + $this->append_token( + $tokens, + array( + 'type' => 'operator', + 'value' => $char, + 'pos' => $i, + ) + ); + ++$i; + continue; + } + + if ( in_array( $char, array( '(', ')', ',' ), true ) ) { + $this->append_token( + $tokens, + array( + 'type' => 'punct', + 'value' => $char, + 'pos' => $i, + ) + ); + ++$i; + continue; + } + + throw new FormulaParseError( + 'cortext_formula_invalid_character', + sprintf( + /* translators: %s: invalid formula character. */ + __( 'Unsupported character in formula: %s', 'cortext' ), + $char + ) + ); + } + + $this->append_token( + $tokens, + array( + 'type' => 'eof', + 'value' => null, + 'pos' => $length, + ) + ); + return $tokens; + } + + /** + * @param array $tokens Formula tokens. + * @param array{type:string,value:mixed,pos:int} $token Token to append. + */ + private function append_token( array &$tokens, array $token ): void { + $tokens[] = $token; + if ( count( $tokens ) > self::MAX_TOKENS ) { + throw new FormulaParseError( + 'cortext_formula_too_complex', + __( 'Formula is too complex.', 'cortext' ) + ); + } + } + + private function assert_string_length( string $value ): void { + if ( strlen( $value ) > self::MAX_STRING_LENGTH ) { + throw new FormulaParseError( + 'cortext_formula_string_too_long', + __( 'Text value is too long.', 'cortext' ) + ); + } + } +} diff --git a/includes/Formula/Materializer.php b/includes/Formula/Materializer.php new file mode 100644 index 00000000..3dcf8391 --- /dev/null +++ b/includes/Formula/Materializer.php @@ -0,0 +1,201 @@ + 0 && + 'formula' === (string) get_post_meta( $field_id, 'type', true ) && + '1' === (string) get_post_meta( $field_id, 'formula_is_volatile', true ) + ) { + return true; + } + } + return false; + } + + public static function recompute_posts( int $collection_id, array $posts ): void { + foreach ( $posts as $post ) { + if ( $post instanceof WP_Post ) { + self::recompute_row( $collection_id, $post->ID ); + } + } + } + + public static function recompute_row( int $collection_id, int $row_id ): void { + $row = get_post( $row_id ); + if ( ! $row instanceof WP_Post ) { + return; + } + + foreach ( self::formula_field_ids_in_order( $collection_id ) as $field_id ) { + self::materialize_field( $row, $field_id ); + } + } + + public static function formula_value( WP_Post $row, int $field_id ): mixed { + $ast = self::stored_ast( $field_id ); + if ( null === $ast ) { + return null; + } + try { + $result = ( new Evaluator() )->evaluate( $ast, $row ); + return $result['value']; + } catch ( FormulaEvalError ) { + return null; + } + } + + private static function materialize_field( WP_Post $row, int $field_id ): void { + $value = self::formula_value( $row, $field_id ); + $key = Relations::meta_key( $field_id ); + if ( null === $value || '' === $value ) { + delete_post_meta( $row->ID, $key ); + return; + } + update_post_meta( $row->ID, $key, $value ); + } + + /** + * @return int[] + */ + private static function formula_field_ids_in_order( int $collection_id ): array { + $field_ids = array(); + foreach ( get_post_meta( $collection_id, 'fields', false ) as $raw_field_id ) { + $field_id = (int) $raw_field_id; + if ( $field_id > 0 && 'formula' === (string) get_post_meta( $field_id, 'type', true ) ) { + $field_ids[] = $field_id; + } + } + + $formula_set = array_fill_keys( $field_ids, true ); + $deps = array(); + foreach ( $field_ids as $field_id ) { + $deps[ $field_id ] = array_values( + array_filter( + self::stored_deps( $field_id ), + static fn( int $dep_id ): bool => isset( $formula_set[ $dep_id ] ) + ) + ); + } + + $ordered = array(); + $visited = array(); + $visit = function ( int $field_id ) use ( &$visit, &$deps, &$visited, &$ordered ): void { + if ( isset( $visited[ $field_id ] ) ) { + return; + } + $visited[ $field_id ] = true; + foreach ( $deps[ $field_id ] ?? array() as $dep_id ) { + $visit( (int) $dep_id ); + } + $ordered[] = $field_id; + }; + foreach ( $field_ids as $field_id ) { + $visit( $field_id ); + } + return $ordered; + } + + /** + * @return int[] + */ + private static function row_ids_for_collection( int $collection_id ): array { + $collection = get_post( $collection_id ); + if ( ! $collection instanceof WP_Post || Collection::POST_TYPE !== $collection->post_type ) { + return array(); + } + $slug = (string) get_post_meta( $collection_id, 'slug', true ); + if ( '' === $slug ) { + return array(); + } + $post_type = CollectionEntries::CPT_PREFIX . $slug; + if ( ! post_type_exists( $post_type ) ) { + return array(); + } + if ( self::is_wordbless_active() ) { + $ids = array(); + foreach ( \WorDBless\Posts::init()->posts as $post ) { + if ( + is_object( $post ) && + $post_type === $post->post_type && + in_array( $post->post_status, array( 'draft', 'pending', 'private', 'publish', 'future', 'inherit' ), true ) + ) { + $ids[] = (int) $post->ID; + } + } + return $ids; + } + return array_map( + 'intval', + get_posts( + array( + 'post_type' => $post_type, + 'post_status' => array( 'draft', 'pending', 'private', 'publish', 'future', 'inherit' ), + 'posts_per_page' => -1, + 'fields' => 'ids', + ) + ) + ); + } + + private static function is_wordbless_active(): bool { + return defined( 'WP_REPAIRING' ) && WP_REPAIRING && class_exists( '\WorDBless\Posts' ); + } + + /** + * @return array|null + */ + private static function stored_ast( int $field_id ): ?array { + $raw = (string) get_post_meta( $field_id, 'formula_ast', true ); + if ( '' === $raw ) { + return null; + } + $decoded = json_decode( $raw, true ); + return is_array( $decoded ) ? $decoded : null; + } + + /** + * @return int[] + */ + private static function stored_deps( int $field_id ): array { + $raw = (string) get_post_meta( $field_id, 'formula_dep_field_ids', true ); + if ( '' === $raw ) { + return array(); + } + $decoded = json_decode( $raw, true ); + return is_array( $decoded ) ? array_values( array_filter( array_map( 'intval', $decoded ) ) ) : array(); + } +} diff --git a/includes/Formula/Parser.php b/includes/Formula/Parser.php new file mode 100644 index 00000000..327ea507 --- /dev/null +++ b/includes/Formula/Parser.php @@ -0,0 +1,201 @@ + */ + private array $tokens; + + private int $index = 0; + + /** + * @param array $tokens Formula tokens. + */ + public function __construct( array $tokens ) { + $this->tokens = $tokens; + } + + /** + * @return array + * @throws FormulaParseError When the expression is invalid. + */ + public function parse(): array { + $node = $this->parse_expression(); + if ( 'eof' !== $this->peek()['type'] ) { + throw new FormulaParseError( + 'cortext_formula_unexpected_token', + __( 'There is extra text after the formula.', 'cortext' ) + ); + } + return $node; + } + + /** + * @return array + */ + private function parse_expression( int $min_precedence = 0 ): array { + $left = $this->parse_prefix(); + + while ( true ) { + $token = $this->peek(); + if ( 'operator' !== $token['type'] ) { + break; + } + + $operator = (string) $token['value']; + $precedence = $this->precedence( $operator ); + if ( $precedence < $min_precedence ) { + break; + } + + $this->advance(); + $right = $this->parse_expression( $precedence + 1 ); + $left = array( + 'node' => 'binary', + 'operator' => $operator, + 'left' => $left, + 'right' => $right, + ); + } + + return $left; + } + + /** + * @return array + */ + private function parse_prefix(): array { + $token = $this->advance(); + + if ( 'number' === $token['type'] ) { + return array( + 'node' => 'literal', + 'type' => 'number', + 'value' => $token['value'], + ); + } + + if ( 'string' === $token['type'] ) { + return array( + 'node' => 'literal', + 'type' => 'text', + 'value' => $token['value'], + ); + } + + if ( 'identifier' === $token['type'] ) { + $name = (string) $token['value']; + $lower = strtolower( $name ); + if ( 'true' === $lower || 'false' === $lower ) { + return array( + 'node' => 'literal', + 'type' => 'checkbox', + 'value' => 'true' === $lower, + ); + } + + if ( $this->match_punct( '(' ) ) { + $args = array(); + if ( ! $this->check_punct( ')' ) ) { + do { + $args[] = $this->parse_expression(); + } while ( $this->match_punct( ',' ) ); + } + $this->consume_punct( + ')', + __( 'Function call is missing a closing parenthesis.', 'cortext' ) + ); + return array( + 'node' => 'call', + 'name' => $lower, + 'args' => $args, + ); + } + + throw new FormulaParseError( + 'cortext_formula_unknown_identifier', + sprintf( + /* translators: %s: unknown formula identifier. */ + __( 'Unknown formula name: %s', 'cortext' ), + $name + ) + ); + } + + if ( 'operator' === $token['type'] && '-' === $token['value'] ) { + return array( + 'node' => 'unary', + 'operator' => '-', + 'argument' => $this->parse_expression( 40 ), + ); + } + + if ( 'punct' === $token['type'] && '(' === $token['value'] ) { + $node = $this->parse_expression(); + $this->consume_punct( + ')', + __( 'Grouped formula is missing a closing parenthesis.', 'cortext' ) + ); + return $node; + } + + throw new FormulaParseError( + 'cortext_formula_unexpected_token', + __( 'Unexpected formula syntax.', 'cortext' ) + ); + } + + private function precedence( string $operator ): int { + return match ( $operator ) { + '=', '==', '!=', '>', '<', '>=', '<=' => 10, + '+', '-' => 20, + '*', '/' => 30, + default => -1, + }; + } + + /** + * @return array{type:string,value:mixed,pos:int} + */ + private function peek(): array { + return $this->tokens[ $this->index ]; + } + + /** + * @return array{type:string,value:mixed,pos:int} + */ + private function advance(): array { + return $this->tokens[ $this->index++ ]; + } + + private function match_punct( string $value ): bool { + if ( $this->check_punct( $value ) ) { + ++$this->index; + return true; + } + return false; + } + + private function check_punct( string $value ): bool { + $token = $this->peek(); + return 'punct' === $token['type'] && $token['value'] === $value; + } + + private function consume_punct( string $value, string $message ): void { + if ( ! $this->match_punct( $value ) ) { + throw new FormulaParseError( 'cortext_formula_expected_token', $message ); + } + } +} From 5b6560f8e6e7bb67393d8624674be54e3d245d0a Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Wed, 27 May 2026 13:11:27 +0200 Subject: [PATCH 02/15] Wire formula fields through REST for #108 --- includes/FieldValues/FieldValueIndex.php | 8 +- includes/Fields/FieldTypeRegistry.php | 24 ++ includes/Formula/Compiler.php | 5 +- includes/Formula/Materializer.php | 34 ++- includes/PostType/Document.php | 37 ++- includes/PostType/Field.php | 97 ++++++- includes/Rest/FieldsController.php | 347 ++++++++++++++++++++++- includes/Rest/RowsController.php | 84 +++++- includes/Rest/RowsFilterQuery.php | 5 +- 9 files changed, 595 insertions(+), 46 deletions(-) diff --git a/includes/FieldValues/FieldValueIndex.php b/includes/FieldValues/FieldValueIndex.php index ee7fb9f4..a1a6386b 100644 --- a/includes/FieldValues/FieldValueIndex.php +++ b/includes/FieldValues/FieldValueIndex.php @@ -11,6 +11,7 @@ defined( 'ABSPATH' ) || exit; +use Cortext\Fields\FieldTypeRegistry; use Cortext\PostType\Document; use Cortext\PostType\Field; use Cortext\Taxonomy\TraitTaxonomy; @@ -1069,13 +1070,14 @@ private function delete_field( int $field_id ): void { } private function index_rows_for_row_field( int $row_id, int $field_id, int $collection_id ): array { - $field_type = (string) get_post_meta( $field_id, 'type', true ); - if ( '' === $field_type || 'rollup' === $field_type ) { + $raw_field_type = (string) get_post_meta( $field_id, 'type', true ); + if ( '' === $raw_field_type || 'rollup' === $raw_field_type ) { return array(); } $key = Relations::meta_key( $field_id ); - $is_multiple = 'multiselect' === $field_type || ( 'relation' === $field_type && Relations::relation_is_multiple( $field_id ) ); + $field_type = FieldTypeRegistry::effective_type_for_field( $field_id, $raw_field_type ); + $is_multiple = 'multiselect' === $raw_field_type || ( 'relation' === $raw_field_type && Relations::relation_is_multiple( $field_id ) ); $stored = get_post_meta( $row_id, $key, ! $is_multiple ); $post_status = (string) get_post_status( $row_id ); $rows = array(); diff --git a/includes/Fields/FieldTypeRegistry.php b/includes/Fields/FieldTypeRegistry.php index 4c4d686f..af627bd7 100644 --- a/includes/Fields/FieldTypeRegistry.php +++ b/includes/Fields/FieldTypeRegistry.php @@ -149,6 +149,13 @@ final class FieldTypeRegistry { 'wp_meta_type' => 'string', 'operators' => array(), ), + 'formula' => array( + 'sortable' => false, + 'filterable' => false, + 'text_like' => false, + 'wp_meta_type' => 'string', + 'operators' => array(), + ), ); /** @@ -213,4 +220,21 @@ public static function capabilities_for( string $type ): array { 'operators' => self::operators_for( $type ), ); } + + public static function effective_type_for_field( int $field_id, string $type = '' ): string { + $field_type = '' !== $type ? $type : (string) get_post_meta( $field_id, 'type', true ); + if ( 'formula' !== $field_type ) { + return $field_type; + } + $result_type = (string) get_post_meta( $field_id, 'formula_result_type', true ); + return self::exists( $result_type ) && 'formula' !== $result_type ? $result_type : 'text'; + } + + public static function capabilities_for_field( int $field_id, string $type = '' ): array { + return self::capabilities_for( self::effective_type_for_field( $field_id, $type ) ); + } + + public static function wp_meta_type_for_field( int $field_id, string $type = '' ): string { + return self::wp_meta_type( self::effective_type_for_field( $field_id, $type ) ); + } } diff --git a/includes/Formula/Compiler.php b/includes/Formula/Compiler.php index 06a2290a..8a2a767d 100644 --- a/includes/Formula/Compiler.php +++ b/includes/Formula/Compiler.php @@ -14,6 +14,7 @@ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped use Cortext\Fields\FieldTypeRegistry; +use Cortext\PostType\Document; use Cortext\PostType\Field; use Cortext\Relations; use WP_Post; @@ -67,7 +68,7 @@ public function compile( string $expression, int $collection_id, int $self_field */ private function collection_field_map( int $collection_id, array $formula_overrides = array() ): array { $map = array(); - foreach ( get_post_meta( $collection_id, 'fields', false ) as $raw_field_id ) { + foreach ( Document::collection_field_ids( $collection_id ) as $raw_field_id ) { $field_id = (int) $raw_field_id; $field = get_post( $field_id ); if ( ! $field instanceof WP_Post || Field::POST_TYPE !== $field->post_type ) { @@ -406,7 +407,7 @@ private function assert_no_cycle( int $collection_id, int $self_field_id, array } $graph = array( $self_field_id => $deps ); - foreach ( get_post_meta( $collection_id, 'fields', false ) as $raw_field_id ) { + foreach ( Document::collection_field_ids( $collection_id ) as $raw_field_id ) { $field_id = (int) $raw_field_id; if ( $field_id < 1 || $field_id === $self_field_id || 'formula' !== (string) get_post_meta( $field_id, 'type', true ) ) { continue; diff --git a/includes/Formula/Materializer.php b/includes/Formula/Materializer.php index 3dcf8391..0d2d74f2 100644 --- a/includes/Formula/Materializer.php +++ b/includes/Formula/Materializer.php @@ -12,9 +12,9 @@ // phpcs:disable Generic.Commenting.DocComment.MissingShort // phpcs:disable Squiz.Commenting.FunctionComment.MissingParamTag,Squiz.Commenting.FunctionComment.ParamNameNoMatch,Squiz.Commenting.FunctionComment.IncorrectTypeHint,Squiz.Commenting.FunctionCommentThrowTag.Missing,Squiz.Commenting.FunctionComment.SpacingAfterParamType -use Cortext\PostType\Collection; -use Cortext\PostType\CollectionEntries; +use Cortext\PostType\Document; use Cortext\Relations; +use Cortext\Taxonomy\TraitTaxonomy; use WP_Post; final class Materializer { @@ -33,7 +33,7 @@ public static function recompute_volatile_collection( int $collection_id ): void } public static function collection_has_volatile_formula( int $collection_id ): bool { - foreach ( get_post_meta( $collection_id, 'fields', false ) as $raw_field_id ) { + foreach ( Document::collection_field_ids( $collection_id ) as $raw_field_id ) { $field_id = (int) $raw_field_id; if ( $field_id > 0 && @@ -93,7 +93,7 @@ private static function materialize_field( WP_Post $row, int $field_id ): void { */ private static function formula_field_ids_in_order( int $collection_id ): array { $field_ids = array(); - foreach ( get_post_meta( $collection_id, 'fields', false ) as $raw_field_id ) { + foreach ( Document::collection_field_ids( $collection_id ) as $raw_field_id ) { $field_id = (int) $raw_field_id; if ( $field_id > 0 && 'formula' === (string) get_post_meta( $field_id, 'type', true ) ) { $field_ids[] = $field_id; @@ -134,15 +134,11 @@ private static function formula_field_ids_in_order( int $collection_id ): array */ private static function row_ids_for_collection( int $collection_id ): array { $collection = get_post( $collection_id ); - if ( ! $collection instanceof WP_Post || Collection::POST_TYPE !== $collection->post_type ) { + if ( ! $collection instanceof WP_Post || ! Document::is_collection_post( $collection ) ) { return array(); } - $slug = (string) get_post_meta( $collection_id, 'slug', true ); - if ( '' === $slug ) { - return array(); - } - $post_type = CollectionEntries::CPT_PREFIX . $slug; - if ( ! post_type_exists( $post_type ) ) { + $term_id = TraitTaxonomy::term_id_for_trait( $collection_id ); + if ( $term_id < 1 ) { return array(); } if ( self::is_wordbless_active() ) { @@ -150,10 +146,13 @@ private static function row_ids_for_collection( int $collection_id ): array { foreach ( \WorDBless\Posts::init()->posts as $post ) { if ( is_object( $post ) && - $post_type === $post->post_type && + Document::POST_TYPE === $post->post_type && in_array( $post->post_status, array( 'draft', 'pending', 'private', 'publish', 'future', 'inherit' ), true ) ) { - $ids[] = (int) $post->ID; + $term_ids = wp_get_object_terms( (int) $post->ID, TraitTaxonomy::TAXONOMY, array( 'fields' => 'ids' ) ); + if ( is_array( $term_ids ) && in_array( $term_id, array_map( 'intval', $term_ids ), true ) ) { + $ids[] = (int) $post->ID; + } } } return $ids; @@ -162,10 +161,17 @@ private static function row_ids_for_collection( int $collection_id ): array { 'intval', get_posts( array( - 'post_type' => $post_type, + 'post_type' => Document::POST_TYPE, 'post_status' => array( 'draft', 'pending', 'private', 'publish', 'future', 'inherit' ), 'posts_per_page' => -1, 'fields' => 'ids', + 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query + array( + 'taxonomy' => TraitTaxonomy::TAXONOMY, + 'field' => 'term_id', + 'terms' => array( $term_id ), + ), + ), ) ) ); diff --git a/includes/PostType/Document.php b/includes/PostType/Document.php index 5c9e9d25..2ad3c683 100644 --- a/includes/PostType/Document.php +++ b/includes/PostType/Document.php @@ -23,6 +23,7 @@ use Cortext\Documents; use Cortext\Fields\FieldTypeRegistry; use Cortext\FieldValues\FieldValueIndex; +use Cortext\Formula\Materializer as FormulaMaterializer; use Cortext\Relations; use Cortext\Taxonomy\TraitTaxonomy; use WP_Error; @@ -101,9 +102,9 @@ public function register_rest_fields(): void { * Trims `field-` meta in the REST response to the document's own * collection. `field-` is registered on every `crtxt_document`, so * without this trim the response carries every collection's fields on every - * document. A row keeps its collection's writable fields. Rollups stay out - * of `meta` because they are computed and read-only, exposed in - * `cortext_hydrated_meta`. Pages and collections keep no field values. + * document. A row keeps its collection's writable fields. Rollups and + * formulas stay out of `meta` because they are computed and read-only, + * exposed in `cortext_hydrated_meta`. Pages and collections keep no field values. * Schema and identity meta stay. * * @param \WP_REST_Response $response Prepared response. @@ -120,7 +121,8 @@ public function limit_field_meta_to_collection( $response, $post ) { $trait_post = ( new Documents() )->find_trait_for_document( $post ); if ( $trait_post instanceof WP_Post ) { foreach ( self::collection_field_ids( (int) $trait_post->ID ) as $field_id ) { - if ( 'rollup' === (string) get_post_meta( $field_id, 'type', true ) ) { + $field_type = (string) get_post_meta( $field_id, 'type', true ); + if ( in_array( $field_type, array( 'rollup', 'formula' ), true ) ) { continue; } $allowed[ 'field-' . $field_id ] = true; @@ -480,7 +482,7 @@ public function register_field_meta(): void { foreach ( $field_ids as $field_id ) { $type = (string) get_post_meta( (int) $field_id, 'type', true ); $wp_meta = FieldTypeRegistry::exists( $type ) - ? FieldTypeRegistry::wp_meta_type( $type ) + ? FieldTypeRegistry::wp_meta_type_for_field( (int) $field_id, $type ) : 'string'; $is_multi = in_array( $type, array( 'multiselect', 'relation' ), true ); // Relation values are row IDs. Storing them as numeric strings in @@ -494,7 +496,7 @@ public function register_field_meta(): void { $config = array( 'type' => $wp_meta, 'single' => ! $is_multi, - 'show_in_rest' => true, + 'show_in_rest' => 'formula' !== $type, ); if ( 'string' === $wp_meta ) { $config['sanitize_callback'] = 'sanitize_text_field'; @@ -580,16 +582,17 @@ public function prepare_meta_updates( $prepared_post, WP_REST_Request $request ) if ( ! is_array( $meta ) || count( $meta ) === 0 ) { return $prepared_post; } - // Rollups are computed and read-only, exposed in `cortext_hydrated_meta`. - // Drop any rollup `field-` from the write, so a stray one (stale - // client, hand-built request) is ignored instead of failing the whole - // save. + // Rollups and formulas are computed and read-only, exposed in + // `cortext_hydrated_meta`. Drop any computed `field-` from the + // write, so a stray one (stale client, hand-built request) is ignored + // instead of failing the whole save. foreach ( $meta as $key => $_value ) { if ( ! is_string( $key ) || ! str_starts_with( $key, 'field-' ) ) { continue; } $field_id = (int) substr( $key, 6 ); - if ( $field_id > 0 && 'rollup' === (string) get_post_meta( $field_id, 'type', true ) ) { + $field_type = $field_id > 0 ? (string) get_post_meta( $field_id, 'type', true ) : ''; + if ( in_array( $field_type, array( 'rollup', 'formula' ), true ) ) { unset( $meta[ $key ] ); } } @@ -676,9 +679,6 @@ public function apply_meta_updates( WP_Post $post, WP_REST_Request $request, boo $meta = $request->get_param( 'meta' ); $has_meta = is_array( $meta ) && count( $meta ) > 0; - if ( ! $has_meta && count( $relation_field_ids ) === 0 ) { - return; - } $trait = ( new Documents() )->find_trait_for_document( $post ); if ( ! $trait instanceof WP_Post ) { return; @@ -717,7 +717,7 @@ public function apply_meta_updates( WP_Post $post, WP_REST_Request $request, boo continue; } $field_type = (string) get_post_meta( $field_id, 'type', true ); - if ( '' === $field_type || 'rollup' === $field_type ) { + if ( '' === $field_type || in_array( $field_type, array( 'rollup', 'formula' ), true ) ) { continue; } $index->index_row_field( $row_id, $field_id, $collection_id ); @@ -731,6 +731,13 @@ public function apply_meta_updates( WP_Post $post, WP_REST_Request $request, boo foreach ( $relation_field_ids as $field_id ) { $index->index_row_field( $row_id, $field_id, $collection_id ); } + + FormulaMaterializer::recompute_row( $collection_id, $row_id ); + foreach ( self::collection_field_ids( $collection_id ) as $field_id ) { + if ( 'formula' === (string) get_post_meta( $field_id, 'type', true ) ) { + $index->index_row_field( $row_id, $field_id, $collection_id ); + } + } } /** diff --git a/includes/PostType/Field.php b/includes/PostType/Field.php index a7b011d7..838da0db 100644 --- a/includes/PostType/Field.php +++ b/includes/PostType/Field.php @@ -338,6 +338,33 @@ public function register_rest_fields(): void { ), ) ); + + register_rest_field( + self::POST_TYPE, + 'cortext_formula', + array( + 'get_callback' => array( $this, 'get_rest_formula' ), + 'schema' => array( + 'type' => array( 'object', 'null' ), + 'context' => array( 'view', 'edit' ), + 'readonly' => true, + 'properties' => array( + 'expression' => array( + 'type' => 'string', + 'readonly' => true, + ), + 'result_type' => array( + 'type' => 'string', + 'readonly' => true, + ), + 'is_volatile' => array( + 'type' => 'boolean', + 'readonly' => true, + ), + ), + ), + ) + ); } /** @@ -350,7 +377,29 @@ public function get_rest_capabilities( array $field_record ): array { $field_id = isset( $field_record['id'] ) ? (int) $field_record['id'] : 0; $type = $field_id > 0 ? (string) get_post_meta( $field_id, 'type', true ) : ''; - return FieldTypeRegistry::capabilities_for( $type ); + return FieldTypeRegistry::capabilities_for_field( $field_id, $type ); + } + + /** + * Returns formula metadata for one field REST record. + * + * Formula edits go through the dedicated formula endpoint so the + * compiled AST, dependencies, result type, and volatility stay in sync. + * + * @param array $field_record REST post response data. + * @return array{expression:string,result_type:string,is_volatile:bool}|null + */ + public function get_rest_formula( array $field_record ): ?array { + $field_id = isset( $field_record['id'] ) ? (int) $field_record['id'] : 0; + if ( $field_id < 1 || 'formula' !== (string) get_post_meta( $field_id, 'type', true ) ) { + return null; + } + + return array( + 'expression' => (string) get_post_meta( $field_id, 'expression', true ), + 'result_type' => (string) get_post_meta( $field_id, 'formula_result_type', true ), + 'is_volatile' => '1' === (string) get_post_meta( $field_id, 'formula_is_volatile', true ), + ); } private function register_meta(): void { @@ -359,7 +408,6 @@ private function register_meta(): void { 'options', 'number_format', 'date_format', - 'expression', 'rollup_aggregator', 'rollup_target_type', 'rollup_target_options', @@ -391,6 +439,17 @@ private function register_meta(): void { ) ); + register_post_meta( + self::POST_TYPE, + 'expression', + array( + 'type' => 'string', + 'single' => true, + 'show_in_rest' => false, + 'sanitize_callback' => 'sanitize_textarea_field', + ) + ); + register_post_meta( self::POST_TYPE, FieldDefaults::META_KEY, @@ -402,6 +461,30 @@ private function register_meta(): void { ) ); + register_post_meta( + self::POST_TYPE, + 'formula_result_type', + array( + 'type' => 'string', + 'single' => true, + 'show_in_rest' => false, + 'sanitize_callback' => 'sanitize_text_field', + ) + ); + + foreach ( array( 'formula_ast', 'formula_dep_field_ids', 'formula_resolved_refs' ) as $key ) { + register_post_meta( + self::POST_TYPE, + $key, + array( + 'type' => 'string', + 'single' => true, + 'show_in_rest' => false, + 'sanitize_callback' => static fn( mixed $value ): string => is_string( $value ) ? $value : '', + ) + ); + } + register_post_meta( self::POST_TYPE, 'related_collection_id', @@ -450,5 +533,15 @@ private function register_meta(): void { 'show_in_rest' => true, ) ); + + register_post_meta( + self::POST_TYPE, + 'formula_is_volatile', + array( + 'type' => 'boolean', + 'single' => true, + 'show_in_rest' => false, + ) + ); } } diff --git a/includes/Rest/FieldsController.php b/includes/Rest/FieldsController.php index c9217fc0..4a326417 100644 --- a/includes/Rest/FieldsController.php +++ b/includes/Rest/FieldsController.php @@ -14,6 +14,9 @@ use Cortext\Fields\FieldDefaults; use Cortext\Fields\FieldTypeConverter; use Cortext\Fields\FieldTypeRegistry; +use Cortext\Formula\Compiler as FormulaCompiler; +use Cortext\Formula\FormulaError; +use Cortext\Formula\Materializer as FormulaMaterializer; use Cortext\OptionPalette; use Cortext\PostType\Document; use Cortext\PostType\Field; @@ -134,6 +137,10 @@ public function register_routes(): void { 'required' => false, 'enum' => self::ROLLUP_AGGREGATORS, ), + 'expression' => array( + 'type' => 'string', + 'required' => false, + ), ), ), ) @@ -161,6 +168,28 @@ public function register_routes(): void { ) ); + register_rest_route( + self::NAMESPACE, + '/fields/(?P\d+)/formula', + array( + array( + 'methods' => 'POST', + 'callback' => array( $this, 'update_formula' ), + 'permission_callback' => array( $this, 'can_edit_field' ), + 'args' => array( + 'field_id' => array( + 'type' => 'integer', + 'required' => true, + ), + 'expression' => array( + 'type' => 'string', + 'required' => true, + ), + ), + ), + ) + ); + register_rest_route( self::NAMESPACE, '/fields/(?P\d+)/options', @@ -312,6 +341,10 @@ public function create( WP_REST_Request $request ): WP_REST_Response|WP_Error { return $this->create_rollup( $request, $collection_id, $title ); } + if ( 'formula' === $type ) { + return $this->create_formula( $request, $collection_id, $title ); + } + $meta = array( 'type' => $type ); if ( $this->type_supports_options( $type ) && is_array( $options ) ) { $meta['options'] = wp_json_encode( $this->normalize_options( $options ) ); @@ -320,6 +353,64 @@ public function create( WP_REST_Request $request ): WP_REST_Response|WP_Error { return $this->insert_and_attach( $collection_id, $title, $meta ); } + public function update_formula( WP_REST_Request $request ): WP_REST_Response|WP_Error { + $field_id = (int) $request->get_param( 'field_id' ); + $field = get_post( $field_id ); + if ( ! $field instanceof WP_Post || Field::POST_TYPE !== $field->post_type ) { + return new WP_Error( + 'cortext_field_not_found', + __( 'Field not found.', 'cortext' ), + array( 'status' => 404 ) + ); + } + + if ( 'formula' !== (string) get_post_meta( $field_id, 'type', true ) ) { + return new WP_Error( + 'cortext_field_type_unsupported', + __( 'This field is not a formula.', 'cortext' ), + array( 'status' => 400 ) + ); + } + + $collection_id = $this->collection_id_for_field( $field_id ); + if ( $collection_id < 1 ) { + return new WP_Error( + 'cortext_field_collection_missing', + __( 'Could not find this field\'s collection.', 'cortext' ), + array( 'status' => 400 ) + ); + } + + $expression = $this->sanitize_formula_expression( $request->get_param( 'expression' ) ); + if ( '' === trim( $expression ) ) { + return $this->formula_expression_required_error(); + } + $compiled = $this->compile_formula_or_error( $expression, $collection_id, $field_id, $this->stored_formula_refs( $field_id ) ); + if ( is_wp_error( $compiled ) ) { + return $compiled; + } + + $dependent_compiled = $this->compile_dependent_formulas_or_error( $collection_id, $field_id, $compiled ); + if ( is_wp_error( $dependent_compiled ) ) { + return $dependent_compiled; + } + + $this->persist_formula( $field_id, $expression, $compiled ); + foreach ( $dependent_compiled as $dependent_field_id => $dependent ) { + $this->persist_formula( (int) $dependent_field_id, $dependent['expression'], $dependent['compiled'] ); + } + FormulaMaterializer::recompute_collection( $collection_id ); + + return new WP_REST_Response( + array( + 'id' => $field_id, + 'expression' => $expression, + 'formula_result_type' => $compiled['result_type'], + ), + 200 + ); + } + public function duplicate( WP_REST_Request $request ): WP_REST_Response|WP_Error { $collection_id = (int) $request->get_param( 'collection_id' ); $field_id = (int) $request->get_param( 'field_id' ); @@ -382,6 +473,11 @@ public function duplicate( WP_REST_Request $request ): WP_REST_Response|WP_Error 'number_format', 'date_format', 'expression', + 'formula_result_type', + 'formula_ast', + 'formula_dep_field_ids', + 'formula_resolved_refs', + 'formula_is_volatile', 'related_collection_id', 'relation_multiple', 'rollup_relation_field_id', @@ -401,7 +497,11 @@ public function duplicate( WP_REST_Request $request ): WP_REST_Response|WP_Error } } - return $this->insert_and_attach( $collection_id, $copy_title, $meta, (string) $field_id ); + $response = $this->insert_and_attach( $collection_id, $copy_title, $meta, (string) $field_id ); + if ( ! is_wp_error( $response ) && 'formula' === $source_type ) { + FormulaMaterializer::recompute_collection( $collection_id ); + } + return $response; } public function update_options( WP_REST_Request $request ): WP_REST_Response|WP_Error { @@ -581,7 +681,7 @@ private function collect_option_tokens( int $field_id, string $target_type, ?arr if ( $trait_term_id < 1 ) { return new WP_Error( 'cortext_field_collection_missing', - __( 'Field collection could not be determined.', 'cortext' ), + __( 'Could not find this field\'s collection.', 'cortext' ), array( 'status' => 400 ) ); } @@ -659,6 +759,20 @@ private function trait_term_id_for_field( int $field_id ): int { return Relations::trait_term_id_for_collection( $collection_id ); } + private function collection_id_for_field( int $field_id ): int { + $field_id_str = (string) $field_id; + + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- field→collection reverse lookup; bounded by a single matching row. + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1", + 'cortext_fields', + $field_id_str + ) + ); + } + /** * Reads the field's current options. * @@ -948,6 +1062,235 @@ private function create_rollup( return $this->insert_and_attach( $collection_id, $title, $meta, $insert_after_id ); } + private function create_formula( + WP_REST_Request $request, + int $collection_id, + string $title, + ?string $insert_after_id = null + ): WP_REST_Response|WP_Error { + $expression = $this->sanitize_formula_expression( $request->get_param( 'expression' ) ); + if ( '' === trim( $expression ) ) { + return $this->formula_expression_required_error(); + } + + $field_id = $this->insert_and_attach_id( + $collection_id, + $title, + array( 'type' => 'formula' ), + $insert_after_id + ); + if ( is_wp_error( $field_id ) ) { + return $field_id; + } + + $compiled = $this->compile_formula_or_error( $expression, $collection_id, (int) $field_id ); + if ( is_wp_error( $compiled ) ) { + $this->detach_field( $collection_id, (int) $field_id ); + wp_delete_post( (int) $field_id, true ); + return $compiled; + } + + $this->persist_formula( (int) $field_id, $expression, $compiled ); + FormulaMaterializer::recompute_collection( $collection_id ); + + return $this->field_response( (int) $field_id, $title, 'formula' ); + } + + private function sanitize_formula_expression( mixed $expression ): string { + return sanitize_textarea_field( (string) $expression ); + } + + private function formula_expression_required_error(): WP_Error { + return new WP_Error( + 'cortext_formula_expression_required', + __( 'Enter a formula.', 'cortext' ), + array( 'status' => 400 ) + ); + } + + /** + * Compiles a formula expression and converts parser errors to REST errors. + * + * @param string $expression Formula expression text. + * @param int $collection_id Collection post ID. + * @param int $field_id Formula field post ID. + * @param array $previous_refs Previously resolved prop refs keyed by name. + * @param array $formula_overrides Compiled formula metadata not yet persisted. + * @return array{ast:array,deps:int[],result_type:string,volatile:bool,refs:array>}|WP_Error + */ + private function compile_formula_or_error( string $expression, int $collection_id, int $field_id, array $previous_refs = array(), array $formula_overrides = array() ): array|WP_Error { + try { + return ( new FormulaCompiler() )->compile( $expression, $collection_id, $field_id, $previous_refs, $formula_overrides ); + } catch ( FormulaError $error ) { + return new WP_Error( + $error->formula_code(), + $error->getMessage(), + array( 'status' => 400 ) + ); + } + } + + /** + * Recompiles every formula that depends on a changed formula, using the + * pending compiled metadata in memory so we can reject the edit before + * persisting stale downstream formulas. + * + * @param int $collection_id Collection post ID. + * @param int $root_field_id Formula field post ID being edited. + * @param array $root_compiled Pending compiled metadata for the edited formula. + * @return array}>|WP_Error + */ + private function compile_dependent_formulas_or_error( int $collection_id, int $root_field_id, array $root_compiled ): array|WP_Error { + $compiled_by_id = array( $root_field_id => $root_compiled ); + $compiled_plan = array(); + + foreach ( $this->dependent_formula_ids_in_order( $collection_id, $root_field_id ) as $dependent_field_id ) { + $expression = (string) get_post_meta( $dependent_field_id, 'expression', true ); + $compiled = $this->compile_formula_or_error( + $expression, + $collection_id, + $dependent_field_id, + $this->stored_formula_refs( $dependent_field_id ), + $compiled_by_id + ); + if ( is_wp_error( $compiled ) ) { + $title = get_the_title( $dependent_field_id ); + if ( '' === $title ) { + $title = '#' . $dependent_field_id; + } + return new WP_Error( + 'cortext_formula_dependent_invalid', + sprintf( + /* translators: 1: dependent formula field title, 2: formula error message. */ + __( '"%1$s" would break after this change: %2$s', 'cortext' ), + $title, + $compiled->get_error_message() + ), + array( + 'status' => 400, + 'dependent_field_id' => $dependent_field_id, + 'dependent_code' => $compiled->get_error_code(), + ) + ); + } + + $compiled_by_id[ $dependent_field_id ] = $compiled; + $compiled_plan[ $dependent_field_id ] = array( + 'expression' => $expression, + 'compiled' => $compiled, + ); + } + + return $compiled_plan; + } + + /** + * Finds formulas that transitively depend on a formula, dependency-first. + * + * @param int $collection_id Collection post ID. + * @param int $root_field_id Formula field post ID that changed. + * @return int[] + */ + private function dependent_formula_ids_in_order( int $collection_id, int $root_field_id ): array { + $formula_ids = array(); + $deps_by_id = array(); + foreach ( Document::collection_field_ids( $collection_id ) as $raw_field_id ) { + $field_id = (int) $raw_field_id; + if ( $field_id < 1 || 'formula' !== (string) get_post_meta( $field_id, 'type', true ) ) { + continue; + } + $formula_ids[] = $field_id; + $deps_by_id[ $field_id ] = $this->stored_formula_deps( $field_id ); + } + + $dependent_set = array(); + $changed = true; + while ( $changed ) { + $changed = false; + foreach ( $deps_by_id as $field_id => $deps ) { + if ( $field_id === $root_field_id || isset( $dependent_set[ $field_id ] ) ) { + continue; + } + foreach ( $deps as $dep_id ) { + if ( $dep_id === $root_field_id || isset( $dependent_set[ $dep_id ] ) ) { + $dependent_set[ $field_id ] = true; + $changed = true; + break; + } + } + } + } + + $ordered = array(); + $visited = array(); + $visit = function ( int $field_id ) use ( &$visit, &$deps_by_id, &$dependent_set, &$visited, &$ordered ): void { + if ( isset( $visited[ $field_id ] ) ) { + return; + } + $visited[ $field_id ] = true; + foreach ( $deps_by_id[ $field_id ] ?? array() as $dep_id ) { + if ( isset( $dependent_set[ $dep_id ] ) ) { + $visit( (int) $dep_id ); + } + } + $ordered[] = $field_id; + }; + + foreach ( $formula_ids as $field_id ) { + if ( isset( $dependent_set[ $field_id ] ) ) { + $visit( $field_id ); + } + } + + return $ordered; + } + + /** + * Stores the compiled formula metadata on a field post. + * + * @param int $field_id Formula field post ID. + * @param string $expression Formula expression text. + * @param array $compiled Compiled formula. + */ + private function persist_formula( int $field_id, string $expression, array $compiled ): void { + update_post_meta( $field_id, 'expression', $expression ); + update_post_meta( $field_id, 'formula_result_type', $compiled['result_type'] ); + update_post_meta( $field_id, 'formula_ast', wp_json_encode( $compiled['ast'] ) ); + update_post_meta( $field_id, 'formula_dep_field_ids', wp_json_encode( $compiled['deps'] ) ); + update_post_meta( $field_id, 'formula_resolved_refs', wp_json_encode( $compiled['refs'] ) ); + update_post_meta( $field_id, 'formula_is_volatile', ! empty( $compiled['volatile'] ) ? '1' : '0' ); + } + + /** + * Reads previously resolved formula references for rename resilience. + * + * @param int $field_id Formula field post ID. + * @return array> + */ + private function stored_formula_refs( int $field_id ): array { + $raw = (string) get_post_meta( $field_id, 'formula_resolved_refs', true ); + if ( '' === $raw ) { + return array(); + } + $decoded = json_decode( $raw, true ); + return is_array( $decoded ) ? $decoded : array(); + } + + /** + * Reads stored formula dependency field IDs. + * + * @param int $field_id Formula field post ID. + * @return int[] + */ + private function stored_formula_deps( int $field_id ): array { + $raw = (string) get_post_meta( $field_id, 'formula_dep_field_ids', true ); + if ( '' === $raw ) { + return array(); + } + $decoded = json_decode( $raw, true ); + return is_array( $decoded ) ? array_values( array_filter( array_map( 'intval', $decoded ) ) ) : array(); + } + /** * Copies target display metadata onto the rollup field so table rendering * does not need to fetch the target field later. diff --git a/includes/Rest/RowsController.php b/includes/Rest/RowsController.php index 2787be6d..63b0af85 100644 --- a/includes/Rest/RowsController.php +++ b/includes/Rest/RowsController.php @@ -22,6 +22,7 @@ use Cortext\Fields\FieldDefaults; use Cortext\Fields\FieldTypeConverter; use Cortext\FieldValues\FieldValueReadQuery; +use Cortext\Formula\Materializer as FormulaMaterializer; use Cortext\PostType\Document; use Cortext\Relations; use Cortext\Taxonomy\TraitTaxonomy; @@ -226,6 +227,10 @@ public function get_rows( WP_REST_Request $request ) { $where_parts = array_values( array_filter( array( $filter_sql['where'], $search_where ) ) ); $where_sql = count( $where_parts ) > 0 ? '( ' . implode( ' AND ', $where_parts ) . ' )' : ''; + if ( $this->query_needs_volatile_formula_materialization( $collection_id, $request ) ) { + FormulaMaterializer::recompute_collection( $collection_id ); + } + // Keep row-formatting metadata local to this rows response. Passing the // context through the helpers avoids stale state in CLI and test runs. $ctx = new RowFormatContext(); @@ -267,6 +272,8 @@ public function get_rows( WP_REST_Request $request ) { $total_pages = (int) $query->max_num_pages; } + FormulaMaterializer::recompute_posts( $collection_id, $posts ); + // Prime the user object cache once before mapping rows so per-row // display name lookups in format_row hit the cache instead of // running N+1 queries. @@ -963,6 +970,8 @@ public function filter_rest_prepare_row( $response, WP_Post $post ): WP_REST_Res $field_ids = Document::collection_field_ids( $collection->ID ); if ( count( $field_ids ) > 0 ) { + FormulaMaterializer::recompute_row( $collection->ID, $post->ID ); + // Keep hydrated values out of `meta`. Those keys are registered as // strings; if autosave sends hydrated objects back, REST rejects the // save with 400. `cortext_hydrated_meta` is read-only display data. @@ -975,6 +984,13 @@ public function filter_rest_prepare_row( $response, WP_Post $post ): WP_REST_Res $hydrated[ $key ] = $this->format_relation_value( $post->ID, $field_id ); } elseif ( 'rollup' === $field_type ) { $hydrated[ $key ] = $this->compute_rollup_value( $post->ID, $field_id ); + } elseif ( 'formula' === $field_type ) { + $hydrated[ $key ] = $this->format_typed_value( + $post->ID, + $field_id, + $this->formula_result_type_for( $field_id ), + false + ); } } @@ -1031,6 +1047,13 @@ private function format_row( WP_Post $post, array $field_ids, array $multi_field $meta[ $key ] = $this->format_relation_value( $post->ID, $field_id, $ctx ); } elseif ( 'rollup' === $field_type ) { $meta[ $key ] = $this->compute_rollup_value( $post->ID, $field_id, $ctx ); + } elseif ( 'formula' === $field_type ) { + $meta[ $key ] = $this->format_typed_value( + $post->ID, + $field_id, + $this->formula_result_type_for( $field_id ), + false + ); } else { $meta[ $key ] = $this->format_typed_value( $post->ID, @@ -1216,6 +1239,54 @@ private function format_typed_value( int $row_id, int $field_id, string $field_t return $stored; } + private function formula_result_type_for( int $field_id ): string { + $type = (string) get_post_meta( $field_id, 'formula_result_type', true ); + return in_array( $type, array( 'text', 'number', 'date', 'datetime', 'checkbox' ), true ) ? $type : 'text'; + } + + private function query_needs_volatile_formula_materialization( int $collection_id, WP_REST_Request $request ): bool { + if ( ! FormulaMaterializer::collection_has_volatile_formula( $collection_id ) ) { + return false; + } + + $sort = $request->get_param( 'sort' ); + if ( is_array( $sort ) && $this->field_key_is_volatile_formula( (string) ( $sort['field'] ?? '' ) ) ) { + return true; + } + + return $this->filters_include_volatile_formula( $request->get_param( 'filters' ) ); + } + + private function filters_include_volatile_formula( mixed $filters ): bool { + if ( ! is_array( $filters ) ) { + return false; + } + + if ( isset( $filters['field'] ) && $this->field_key_is_volatile_formula( (string) $filters['field'] ) ) { + return true; + } + + foreach ( $filters as $filter ) { + if ( is_array( $filter ) && $this->filters_include_volatile_formula( $filter ) ) { + return true; + } + } + return false; + } + + private function field_key_is_volatile_formula( string $field_key ): bool { + if ( ! str_starts_with( $field_key, 'field-' ) ) { + return false; + } + + $field_id = $this->field_id_from_key( $field_key ); + return ( + $field_id > 0 && + 'formula' === (string) get_post_meta( $field_id, 'type', true ) && + '1' === (string) get_post_meta( $field_id, 'formula_is_volatile', true ) + ); + } + /** * Reads valid values for a select or multiselect field. * @@ -1328,7 +1399,7 @@ private function collection_definition( WP_Post $collection ): array { * Builds lightweight field definitions for the response. * * @param int[] $field_ids Field post IDs. - * @return array + * @return array */ private function field_definitions( array $field_ids ): array { $definitions = array(); @@ -1341,11 +1412,12 @@ private function field_definitions( array $field_ids ): array { $options = get_post_meta( $field_id, 'options', true ); $definitions[] = array( - 'id' => $field_id, - 'label' => $field->post_title, - 'type' => $type, - 'description' => (string) get_post_meta( $field_id, 'description', true ), - 'options' => empty( $options ) ? null : $options, + 'id' => $field_id, + 'label' => $field->post_title, + 'type' => $type, + 'description' => (string) get_post_meta( $field_id, 'description', true ), + 'options' => empty( $options ) ? null : $options, + 'formulaResultType' => 'formula' === $type ? $this->formula_result_type_for( $field_id ) : null, ); } return $definitions; diff --git a/includes/Rest/RowsFilterQuery.php b/includes/Rest/RowsFilterQuery.php index f230ea5e..f33f4f3d 100644 --- a/includes/Rest/RowsFilterQuery.php +++ b/includes/Rest/RowsFilterQuery.php @@ -80,8 +80,9 @@ public function field_schema_for( int $collection_id ): array { if ( $field_id < 1 ) { continue; } - $type = (string) get_post_meta( $field_id, 'type', true ); - $key = "field-{$field_id}"; + $raw_type = (string) get_post_meta( $field_id, 'type', true ); + $type = FieldTypeRegistry::effective_type_for_field( $field_id, $raw_type ); + $key = "field-{$field_id}"; $schema[ $key ] = array( 'id' => $field_id, From e4a230b902082d42e683230355ae017cdbfc7782 Mon Sep 17 00:00:00 2001 From: priethor <27339341+priethor@users.noreply.github.com> Date: Wed, 27 May 2026 13:11:31 +0200 Subject: [PATCH 03/15] Add formula field editor UI for #108 --- src/components/fields/AddFieldPopover.js | 36 +- src/components/fields/ColumnHeaderActions.js | 5 +- src/components/fields/FieldActionsMenu.js | 50 ++ src/components/fields/FormulaConfig.js | 829 +++++++++++++++++++ src/components/fields/FormulaConfig.scss | 331 ++++++++ src/components/fields/fieldTypes.js | 2 + src/hooks/fieldMapping.js | 81 +- src/hooks/publicFieldMapping.js | 28 +- src/hooks/useCollectionRows.js | 6 +- src/hooks/useFieldMutations.js | 29 + 10 files changed, 1379 insertions(+), 18 deletions(-) create mode 100644 src/components/fields/FormulaConfig.js create mode 100644 src/components/fields/FormulaConfig.scss diff --git a/src/components/fields/AddFieldPopover.js b/src/components/fields/AddFieldPopover.js index 646ae841..717fbabb 100644 --- a/src/components/fields/AddFieldPopover.js +++ b/src/components/fields/AddFieldPopover.js @@ -16,6 +16,7 @@ import { buildFieldListQuery } from '../../hooks/useCollectionFields'; import { useCollectionFieldsContext } from '../CollectionFieldsContext'; import { useCreateField } from '../../hooks/useFieldMutations'; import { FIELD_TYPES, FieldTypeIcon, fieldTypeLabel } from './fieldTypes'; +import FormulaConfig from './FormulaConfig'; const RELATION_LIMIT_OPTIONS = [ { value: 'many', label: __( 'No limit', 'cortext' ) }, @@ -156,7 +157,7 @@ function RelationConfig( { __nextHasNoMarginBottom /> @@ -387,7 +388,7 @@ function RollupConfig( { __nextHasNoMarginBottom /> { @@ -451,7 +452,11 @@ export default function AddFieldPopover( { collectionId, onCreate } ) { return; } setSubmitError( '' ); - if ( chosenType === 'relation' || chosenType === 'rollup' ) { + if ( + chosenType === 'relation' || + chosenType === 'rollup' || + chosenType === 'formula' + ) { setConfigType( chosenType ); return; } @@ -488,13 +493,16 @@ export default function AddFieldPopover( { collectionId, onCreate } ) { ? configuredType.label : fieldTypeLabel( 'text' ); let nameLabel = __( 'Name', 'cortext' ); - let namePlaceholder = __( 'Type property name…', 'cortext' ); + let namePlaceholder = __( 'Type field name…', 'cortext' ); if ( configType === 'relation' ) { nameLabel = __( 'Relation name', 'cortext' ); namePlaceholder = __( 'Relation name', 'cortext' ); } else if ( configType === 'rollup' ) { nameLabel = __( 'Rollup name', 'cortext' ); namePlaceholder = __( 'Rollup name', 'cortext' ); + } else if ( configType === 'formula' ) { + nameLabel = __( 'Formula name', 'cortext' ); + namePlaceholder = __( 'Formula name', 'cortext' ); } let configuration = null; @@ -524,6 +532,22 @@ export default function AddFieldPopover( { collectionId, onCreate } ) { onError={ setSubmitError } /> ); + } else if ( configType === 'formula' ) { + configuration = ( + setConfigType( null ) } + onError={ setSubmitError } + onSubmit={ async ( expression ) => { + const created = await run( { + title: title.trim() || fallbackTitle, + type: 'formula', + expression, + } ); + onCreate?.( created ); + } } + /> + ); } return ( diff --git a/src/components/fields/ColumnHeaderActions.js b/src/components/fields/ColumnHeaderActions.js index 6f9c04b7..8dfc6922 100644 --- a/src/components/fields/ColumnHeaderActions.js +++ b/src/components/fields/ColumnHeaderActions.js @@ -514,7 +514,10 @@ function AddFieldTrigger( { collectionId, onFieldCreated, onRowsChanged } ) { collectionId={ collectionId } onCreate={ ( created ) => { onFieldCreated?.( created ); - if ( created?.type === 'rollup' ) { + if ( + created?.type === 'rollup' || + created?.type === 'formula' + ) { onRowsChanged?.(); } onClose(); diff --git a/src/components/fields/FieldActionsMenu.js b/src/components/fields/FieldActionsMenu.js index 496dbdc9..4df98a13 100644 --- a/src/components/fields/FieldActionsMenu.js +++ b/src/components/fields/FieldActionsMenu.js @@ -20,6 +20,7 @@ import { chevronRight, cog, copy, pencil, trash } from '@wordpress/icons'; import ChangeFieldTypePopover from './ChangeFieldTypePopover'; import EditOptionsPopover from './EditOptionsPopover'; import FieldFormatPopover from './FieldFormatPopover'; +import FormulaConfig from './FormulaConfig'; import FieldSettingsPopover from './FieldSettingsPopover'; import RenameFieldInline from './RenameFieldInline'; import { @@ -30,6 +31,7 @@ import Infotip from '../Infotip'; import { useDeleteField, useDuplicateField, + useUpdateFormulaExpression, } from '../../hooks/useFieldMutations'; const { Menu } = unlock( componentsPrivateApis ); @@ -66,6 +68,7 @@ export default function FieldActionsMenu( { const [ isMenuOpen, setIsMenuOpen ] = useState( false ); const [ isFormatting, setIsFormatting ] = useState( false ); const [ isEditingOptions, setIsEditingOptions ] = useState( false ); + const [ isEditingFormula, setIsEditingFormula ] = useState( false ); const [ isEditingSettings, setIsEditingSettings ] = useState( false ); const [ isChangingType, setIsChangingType ] = useState( false ); const [ shouldFocusFormat, setShouldFocusFormat ] = useState( false ); @@ -75,6 +78,7 @@ export default function FieldActionsMenu( { const optionsAnchorRef = useRef( null ); const duplicate = useDuplicateField( collectionId ); const remove = useDeleteField( collectionId ); + const updateFormula = useUpdateFormulaExpression( collectionId ); const { fields } = useCollectionFieldsContext(); const mappedField = useMappedField( recordId ); const activeField = useMemo( () => { @@ -100,12 +104,14 @@ export default function FieldActionsMenu( { activeField?.type; const canFormat = FORMATTABLE_TYPES.has( fieldType ); const supportsOptions = TYPES_WITH_OPTIONS.has( fieldType ); + const supportsFormula = fieldType === 'formula'; const canChangeType = Boolean( fieldType ) && ! UNCONVERTIBLE_SOURCE_TYPES.has( fieldType ); const initialOptions = useMemo( () => ( supportsOptions ? activeField?.cortextElements ?? [] : [] ), [ supportsOptions, activeField ] ); + const [ formulaError, setFormulaError ] = useState( '' ); const dependentRollups = useMemo( () => { const fieldList = Array.isArray( fields ) ? fields : []; return fieldList.filter( @@ -370,6 +376,18 @@ export default function FieldActionsMenu( { ) : null } + { supportsFormula ? ( + { + setFormulaError( '' ); + setIsEditingFormula( true ); + } } + > + + { __( 'Edit formula', 'cortext' ) } + + + ) : null } { canChangeType ? ( setIsChangingType( true ) } @@ -481,6 +499,38 @@ export default function FieldActionsMenu( { /> ) : null } + { isEditingFormula && supportsFormula ? ( + setIsEditingFormula( false ) } + focusOnMount="firstElement" + className="cortext-formula-popover-host" + > +
+ setIsEditingFormula( false ) } + onError={ setFormulaError } + onSubmit={ async ( expression ) => { + setFormulaError( '' ); + await updateFormula.run( recordId, expression ); + setIsEditingFormula( false ); + onRowsChanged?.(); + } } + /> +
+
+ ) : null } { isEditingSettings ? ( ', '<', '>=', '<=' ]; +const AUTOCOMPLETE_CONTROL_KEYS = new Set( [ + 'ArrowDown', + 'ArrowUp', + 'Enter', + 'Tab', + 'Escape', +] ); +const FUNCTIONS = [ + 'concat()', + 'length()', + 'upper()', + 'lower()', + 'contains()', + 'if()', + 'now()', + 'dateBetween()', + 'formatDate()', +]; + +const FUNCTION_COMPLETIONS = [ + { + label: 'field', + signature: 'field("Name")', + insertText: 'field("")', + caretOffset: 'field("'.length, + type: 'field', + description: __( 'Use a field value.', 'cortext' ), + }, + { + label: 'prop', + signature: 'prop("Name")', + insertText: 'prop("")', + caretOffset: 'prop("'.length, + type: 'alias', + description: __( 'Same as field().', 'cortext' ), + }, + { + label: 'concat', + signature: 'concat(value, ...)', + insertText: 'concat()', + caretOffset: 'concat('.length, + type: 'text', + description: __( 'Join values together.', 'cortext' ), + }, + { + label: 'length', + signature: 'length(text)', + insertText: 'length()', + caretOffset: 'length('.length, + type: 'number', + description: __( 'Count characters.', 'cortext' ), + }, + { + label: 'upper', + signature: 'upper(text)', + insertText: 'upper()', + caretOffset: 'upper('.length, + type: 'text', + description: __( 'Convert text to uppercase.', 'cortext' ), + }, + { + label: 'lower', + signature: 'lower(text)', + insertText: 'lower()', + caretOffset: 'lower('.length, + type: 'text', + description: __( 'Convert text to lowercase.', 'cortext' ), + }, + { + label: 'contains', + signature: 'contains(text, search)', + insertText: 'contains(, )', + caretOffset: 'contains('.length, + type: 'checkbox', + description: __( 'Check whether text contains a match.', 'cortext' ), + }, + { + label: 'if', + signature: 'if(condition, then, else)', + insertText: 'if(, , )', + caretOffset: 'if('.length, + type: 'same type', + description: __( 'Choose between two values.', 'cortext' ), + }, + { + label: 'now', + signature: 'now()', + insertText: 'now()', + caretOffset: 'now()'.length, + type: 'datetime', + description: __( 'The current date and time.', 'cortext' ), + }, + { + label: 'dateBetween', + signature: 'dateBetween(a, b, "days")', + insertText: 'dateBetween(, , "days")', + caretOffset: 'dateBetween('.length, + type: 'number', + description: __( 'Time between two dates.', 'cortext' ), + }, + { + label: 'formatDate', + signature: 'formatDate(date, format)', + insertText: 'formatDate(, "YYYY-MM-DD")', + caretOffset: 'formatDate('.length, + type: 'text', + description: __( 'Show a date in a format.', 'cortext' ), + }, +]; + +const HIGHLIGHT_FUNCTIONS = new Set( [ + 'field', + 'prop', + 'concat', + 'length', + 'upper', + 'lower', + 'contains', + 'if', + 'now', + 'datebetween', + 'formatdate', +] ); + +function isNameStart( char ) { + return /[A-Za-z_]/.test( char ); +} + +function isNamePart( char ) { + return /[A-Za-z0-9_]/.test( char ); +} + +function formulaTokens( value ) { + const tokens = []; + let index = 0; + + while ( index < value.length ) { + const char = value[ index ]; + const next = value[ index + 1 ] ?? ''; + + if ( /\s/.test( char ) ) { + const start = index; + while ( index < value.length && /\s/.test( value[ index ] ) ) { + index += 1; + } + tokens.push( { text: value.slice( start, index ), type: 'plain' } ); + continue; + } + + if ( char === '"' ) { + const start = index; + index += 1; + while ( index < value.length ) { + if ( value[ index ] === '\\' ) { + index += 2; + continue; + } + if ( value[ index ] === '"' ) { + index += 1; + break; + } + index += 1; + } + tokens.push( { + text: value.slice( start, index ), + type: /(?:field|prop)\s*\(\s*$/i.test( value.slice( 0, start ) ) + ? 'field' + : 'string', + } ); + continue; + } + + if ( /\d/.test( char ) || ( char === '.' && /\d/.test( next ) ) ) { + const start = index; + let hasDot = false; + while ( index < value.length ) { + const current = value[ index ]; + if ( current === '.' ) { + if ( hasDot ) { + break; + } + hasDot = true; + index += 1; + continue; + } + if ( ! /\d/.test( current ) ) { + break; + } + index += 1; + } + tokens.push( { + text: value.slice( start, index ), + type: 'number', + } ); + continue; + } + + if ( isNameStart( char ) ) { + const start = index; + index += 1; + while ( index < value.length && isNamePart( value[ index ] ) ) { + index += 1; + } + const text = value.slice( start, index ); + const lower = text.toLowerCase(); + let lookahead = index; + while ( /\s/.test( value[ lookahead ] ?? '' ) ) { + lookahead += 1; + } + const isFunction = + value[ lookahead ] === '(' && HIGHLIGHT_FUNCTIONS.has( lower ); + let type = 'unknown'; + if ( lower === 'true' || lower === 'false' ) { + type = 'boolean'; + } else if ( isFunction ) { + type = 'function'; + } + tokens.push( { text, type } ); + continue; + } + + const two = char + next; + if ( [ '==', '!=', '>=', '<=' ].includes( two ) ) { + tokens.push( { text: two, type: 'operator' } ); + index += 2; + continue; + } + + if ( [ '+', '-', '*', '/', '=', '>', '<' ].includes( char ) ) { + tokens.push( { text: char, type: 'operator' } ); + index += 1; + continue; + } + + if ( [ '(', ')', ',' ].includes( char ) ) { + tokens.push( { text: char, type: 'punctuation' } ); + index += 1; + continue; + } + + tokens.push( { text: char, type: 'unknown' } ); + index += 1; + } + + if ( value.endsWith( '\n' ) ) { + tokens.push( { text: ' ', type: 'plain' } ); + } + + return tokens; +} + +function isInsideString( value, caret ) { + let inString = false; + for ( let index = 0; index < caret; index += 1 ) { + const char = value[ index ]; + if ( char === '\\' ) { + index += 1; + continue; + } + if ( char === '"' ) { + inString = ! inString; + } + } + return inString; +} + +function formulaAutocomplete( value, caret ) { + const before = value.slice( 0, caret ); + const fieldMatch = before.match( /(?:field|prop)\s*\(\s*"([^"]*)$/i ); + if ( fieldMatch ) { + return { + kind: 'field', + start: caret - fieldMatch[ 1 ].length, + end: caret, + query: fieldMatch[ 1 ], + }; + } + + if ( isInsideString( value, caret ) ) { + return null; + } + + const functionMatch = before.match( + /(^|[^A-Za-z0-9_])([A-Za-z_][A-Za-z0-9_]*)$/ + ); + if ( ! functionMatch ) { + return null; + } + + return { + kind: 'function', + start: caret - functionMatch[ 2 ].length, + end: caret, + query: functionMatch[ 2 ], + }; +} + +function escapePropName( name ) { + return name.replaceAll( '\\', '\\\\' ).replaceAll( '"', '\\"' ); +} + +function uniqueProperties( fields, excludeRecordId ) { + const options = [ ...SYSTEM_FIELDS ]; + const seen = new Set( options.map( ( option ) => option.label ) ); + fields.forEach( ( field ) => { + if ( + field.recordId === excludeRecordId || + ! field.label || + UNSUPPORTED_FIELD_REF_TYPES.has( field.cortextType ) + ) { + return; + } + if ( seen.has( field.label ) ) { + return; + } + seen.add( field.label ); + options.push( { + label: field.label, + type: field.formulaResultType ?? field.cortextType ?? 'text', + } ); + } ); + return options; +} + +export default function FormulaConfig( { + initialExpression = '', + isBusy = false, + onBack, + onError, + onSubmit, + errorMessage = '', + submitLabel = __( 'Create formula', 'cortext' ), + backLabel = __( 'Back', 'cortext' ), + excludeRecordId, +} ) { + const textareaRef = useRef( null ); + const editorId = useRef( + `cortext-formula-expression-${ Math.random() + .toString( 36 ) + .slice( 2 ) }` + ); + const { fields } = useCollectionFieldsContext(); + const [ expression, setExpression ] = useState( initialExpression ); + const [ activeMatch, setActiveMatch ] = useState( null ); + const [ activeIndex, setActiveIndex ] = useState( 0 ); + const [ editorScroll, setEditorScroll ] = useState( { + left: 0, + top: 0, + } ); + const [ isReferenceOpen, setIsReferenceOpen ] = useState( false ); + + const properties = useMemo( + () => uniqueProperties( fields, excludeRecordId ), + [ fields, excludeRecordId ] + ); + const highlightedTokens = useMemo( + () => formulaTokens( expression ), + [ expression ] + ); + const suggestions = useMemo( () => { + if ( ! activeMatch ) { + return []; + } + const needle = activeMatch.query.toLowerCase(); + if ( activeMatch.kind === 'function' ) { + return FUNCTION_COMPLETIONS.filter( ( completion ) => + `${ completion.label } ${ completion.signature }` + .toLowerCase() + .includes( needle ) + ) + .slice( 0, 8 ) + .map( ( completion ) => ( { + ...completion, + kind: 'function', + } ) ); + } + return properties + .filter( ( property ) => + property.label.toLowerCase().includes( needle ) + ) + .slice( 0, 8 ) + .map( ( property ) => ( { + ...property, + kind: 'field', + } ) ); + }, [ activeMatch, properties ] ); + + const syncAutocomplete = ( nextValue, nextCaret ) => { + const match = formulaAutocomplete( nextValue, nextCaret ); + setActiveMatch( match ); + setActiveIndex( 0 ); + }; + + const selectSuggestion = ( suggestion ) => { + if ( ! activeMatch || ! suggestion ) { + return; + } + + let insertText; + let nextCaret; + if ( suggestion.kind === 'function' ) { + insertText = suggestion.insertText; + nextCaret = activeMatch.start + suggestion.caretOffset; + } else { + const escaped = escapePropName( suggestion.label ); + const hasClosingSuffix = expression + .slice( activeMatch.end ) + .startsWith( '")' ); + const suffix = hasClosingSuffix ? '' : '")'; + insertText = escaped + suffix; + nextCaret = + activeMatch.start + + escaped.length + + ( hasClosingSuffix ? 2 : suffix.length ); + } + + const next = + expression.slice( 0, activeMatch.start ) + + insertText + + expression.slice( activeMatch.end ); + setExpression( next ); + syncAutocomplete( next, nextCaret ); + window.requestAnimationFrame( () => { + textareaRef.current?.setSelectionRange( nextCaret, nextCaret ); + textareaRef.current?.focus(); + } ); + }; + + const submit = async () => { + if ( isBusy ) { + return; + } + if ( ! expression.trim() ) { + onError?.( __( 'Enter a formula.', 'cortext' ) ); + return; + } + try { + await onSubmit( expression.trim() ); + } catch ( apiError ) { + onError?.( + apiError?.message || + __( 'Could not save the formula.', 'cortext' ) + ); + } + }; + + return ( +
+ { errorMessage ? ( + + { errorMessage } + + ) : null } +
+
+ + +
+
+ +