diff --git a/docs/tech-debt.md b/docs/tech-debt.md index 3f5b9853..86a91c26 100644 --- a/docs/tech-debt.md +++ b/docs/tech-debt.md @@ -674,3 +674,16 @@ The rows endpoint is still the hard case. `RowsController` unit tests cover rout **Where.** `Document::register_field_meta` in `includes/PostType/Document.php`. **Solution.** Call `update_meta_cache( 'post', $field_ids )` once before the foreach so the subsequent `get_post_meta` calls are cache hits. The field-id list is already in memory from the preceding `get_posts`, so the warmup is a one-liner. + + +**Formula values materialize synchronously.** + +**What.** Formula output is stored in the same `field-` row meta as normal fields. That keeps rows, exports, filters, sorts, and the field-value index reading one canonical value, but it means Cortext has to keep that stored value fresh. In v0, it does that synchronously: all formulas on a row after row writes, visible rows after list reads, and every row in the collection after a formula is created or edited. Volatile formulas such as `now()` get one extra refresh only when a request sorts or filters by that volatile formula, because SQL and the sidecar index read the materialized meta. + +This is fine while collections are small and formulas are few. It becomes the wrong shape for large tables with several dependent formulas, or for sorting thousands of rows by something like `dateBetween(now(), field("Created"), "days")`. The code stores dependency ids already, but it does not yet use them as a dirty graph or background job plan. + +**Where.** `includes/Formula/Materializer.php`, formula refresh calls in `includes/PostType/Document.php`, `includes/Rest/RowsController.php`, and `includes/Rest/FieldsController.php`, plus formula indexing in `includes/FieldValues/FieldValueIndex.php`. + +**Solution.** For non-volatile formulas, keep materialized row meta as the source of truth and make refresh narrower. Row writes should recompute only formulas that depend on the changed field, in dependency order. Formula create/update can mark affected rows dirty and process them in batches instead of blocking the request on the whole collection. + +For volatile formulas, treat materialized meta as a cache rather than the source of truth. Define explicit refresh points, with view load as the obvious baseline, and document the staleness contract. Once that exists, collection-wide recompute calls can shrink to those refresh points plus repair tools and migrations. diff --git a/includes/FieldValues/FieldValueIndex.php b/includes/FieldValues/FieldValueIndex.php index ee7fb9f4..893e7635 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(); } + $field_type = FieldTypeRegistry::effective_type_for_field( $field_id, $raw_field_type ); $key = Relations::meta_key( $field_id ); - $is_multiple = 'multiselect' === $field_type || ( 'relation' === $field_type && Relations::relation_is_multiple( $field_id ) ); + $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 new file mode 100644 index 00000000..7771cfb8 --- /dev/null +++ b/includes/Formula/Compiler.php @@ -0,0 +1,468 @@ + 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 ( 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 ) { + 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', + __( 'This formula is nested too deeply.', 'cortext' ) + ); + } + + ++$count; + if ( $count > self::MAX_AST_NODES ) { + throw new FormulaParseError( + 'cortext_formula_too_complex', + __( 'This 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', + __( 'We couldn\'t read this formula.', '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', + __( 'The minus sign can only be used with 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', + __( 'Compare values of the same kind.', '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 only work with 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', + __( 'Use a quoted field name, like field("Price"). prop("Price") works too.', '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. */ + __( 'No field named %s was found.', '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. Rename one or use a unique field.', '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 use itself.', 'cortext' ) + ); + } + if ( $field['multiple'] || in_array( $field['type'], array( 'relation', 'rollup' ), true ) ) { + throw new FormulaParseError( + 'cortext_formula_unsupported_target_type', + __( 'For now, formulas can only use single-value fields. Multi-select, relation, and rollup fields are not available yet.', '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 ( 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; + } + $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', + __( 'These 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', + __( 'These 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..b1eb69f7 --- /dev/null +++ b/includes/Formula/Evaluator.php @@ -0,0 +1,253 @@ + $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', + __( 'We couldn\'t calculate this formula.', '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', + __( 'The minus sign can only be used with 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' => $this->value_to_text( $left['value'] ?? null ) . $this->value_to_text( $right['value'] ?? null ), + 'type' => 'text', + ); + } + + if ( 'number' !== $left['type'] || 'number' !== $right['type'] ) { + throw new FormulaEvalError( + 'cortext_formula_type_mismatch', + __( 'Math operators only work with 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', + __( 'You cannot divide by zero.', 'cortext' ) + ); + } + + return array( + 'value' => match ( $operator ) { + '+' => $a + $b, + '-' => $a - $b, + '*' => $a * $b, + '/' => $a / $b, + default => null, + }, + 'type' => 'number', + ); + } + + private function value_to_text( mixed $value ): string { + if ( null === $value ) { + return ''; + } + if ( is_bool( $value ) ) { + return $value ? 'true' : 'false'; + } + return (string) $value; + } + + /** + * @param array $node + * @return array{value:mixed,type:string} + */ + private function evaluate_call( array $node, WP_Post $row ): array { + if ( 'if' === (string) ( $node['name'] ?? '' ) ) { + return $this->evaluate_if_call( $node, $row ); + } + + $args = array_map( + fn( array $arg ): array => $this->evaluate_node( $arg, $row ), + (array) $node['args'] + ); + return Functions::evaluate( (string) $node['name'], $args ); + } + + /** + * @param array $node + * @return array{value:mixed,type:string} + */ + private function evaluate_if_call( array $node, WP_Post $row ): array { + $args = array_values( (array) ( $node['args'] ?? array() ) ); + if ( 3 !== count( $args ) ) { + throw new FormulaEvalError( + 'cortext_formula_invalid_arity', + __( 'if() needs condition, then, and else values.', 'cortext' ) + ); + } + + $condition = $this->evaluate_node( (array) $args[0], $row ); + $branch = ! empty( $condition['value'] ) ? $args[1] : $args[2]; + $result = $this->evaluate_node( (array) $branch, $row ); + $type = (string) ( $node['type'] ?? $result['type'] ); + + return array( + 'value' => $result['value'] ?? null, + 'type' => '' !== $type ? $type : (string) $result['type'], + ); + } + + /** + * @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 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' => mb_strlen( self::to_text( $args[0] ?? array( 'value' => '' ) ), 'UTF-8' ), + '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', + __( 'This formula calls an unknown function.', 'cortext' ) + ), + }; + } + + private static function require_min_arity( string $name, int $actual, int $minimum, string $return_type ): string { + if ( $actual < $minimum ) { + $message = 1 === $minimum + ? sprintf( + /* translators: %s: function name. */ + __( '%s() needs at least one value.', 'cortext' ), + $name + ) + : sprintf( + /* translators: 1: function name, 2: minimum value count. */ + __( '%1$s() needs at least %2$d values.', 'cortext' ), + $name, + $minimum + ); + + throw new FormulaParseError( + 'cortext_formula_invalid_arity', + $message + ); + } + 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 ) ) { + $expected_count = count( $expected ); + $message = match ( $expected_count ) { + 0 => sprintf( + /* translators: %s: function name. */ + __( '%s() does not take any values.', 'cortext' ), + $name + ), + 1 => sprintf( + /* translators: %s: function name. */ + __( '%s() needs one value.', 'cortext' ), + $name + ), + default => sprintf( + /* translators: 1: function name, 2: value count. */ + __( '%1$s() needs %2$d values.', 'cortext' ), + $name, + $expected_count + ), + }; + + throw new FormulaParseError( + 'cortext_formula_invalid_arity', + $message + ); + } + + 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. */ + __( 'Value %2$d in %1$s() must be %3$s.', '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 condition, then, and else values.', 'cortext' ) + ); + } + if ( ! self::type_matches( (string) $args[0]['type'], 'checkbox' ) ) { + throw new FormulaParseError( + 'cortext_formula_type_mismatch', + __( 'The if() condition must be true or false.', 'cortext' ) + ); + } + if ( $args[1]['type'] !== $args[2]['type'] ) { + throw new FormulaParseError( + 'cortext_formula_mixed_if', + __( 'The then and else values in if() must use 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 two dates and a unit.', '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', + __( 'The dateBetween() unit must be text, like "days".', '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 a date and a format.', '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 ); + } + $sign = $seconds < 0 ? -1 : 1; + $distance = abs( $seconds ); + return $sign * match ( $unit ) { + 'minutes', 'minute' => floor( $distance / MINUTE_IN_SECONDS ), + 'hours', 'hour' => floor( $distance / HOUR_IN_SECONDS ), + 'weeks', 'week' => floor( $distance / WEEK_IN_SECONDS ), + default => floor( $distance / 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..96561f04 --- /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', + __( 'This 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. */ + __( 'Formulas cannot use this character: %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', + __( 'This 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', + __( 'This text value is too long.', 'cortext' ) + ); + } + } +} diff --git a/includes/Formula/Materializer.php b/includes/Formula/Materializer.php new file mode 100644 index 00000000..dd60d2c3 --- /dev/null +++ b/includes/Formula/Materializer.php @@ -0,0 +1,208 @@ + 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 ( 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; + } + } + + $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 || ! Document::is_collection_post( $collection ) ) { + return array(); + } + $term_id = TraitTaxonomy::term_id_for_trait( $collection_id ); + if ( $term_id < 1 ) { + return array(); + } + if ( self::is_wordbless_active() ) { + $ids = array(); + foreach ( \WorDBless\Posts::init()->posts as $post ) { + if ( + is_object( $post ) && + Document::POST_TYPE === $post->post_type && + in_array( $post->post_status, array( 'draft', 'pending', 'private', 'publish', 'future', 'inherit' ), true ) + ) { + $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; + } + return array_map( + 'intval', + get_posts( + array( + '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 ), + ), + ), + ) + ) + ); + } + + 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..c73cdf55 --- /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', + __( 'Remove the extra text after the formula ends.', '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( + ')', + __( 'Add the closing parenthesis for this function.', 'cortext' ) + ); + return array( + 'node' => 'call', + 'name' => $lower, + 'args' => $args, + ); + } + + throw new FormulaParseError( + 'cortext_formula_unknown_identifier', + sprintf( + /* translators: %s: unknown formula identifier. */ + __( 'We don\'t recognize %s in formulas.', '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( + ')', + __( 'Add the closing parenthesis for this group.', 'cortext' ) + ); + return $node; + } + + throw new FormulaParseError( + 'cortext_formula_unexpected_token', + __( 'This formula has unexpected 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 ); + } + } +} diff --git a/includes/PostType/Document.php b/includes/PostType/Document.php index 5c9e9d25..92297865 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_id = (int) substr( $key, 6 ); + $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,10 +679,7 @@ 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 ); + $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..adad179a 100644 --- a/includes/PostType/Field.php +++ b/includes/PostType/Field.php @@ -152,11 +152,13 @@ public function register(): void { * * 1. Drops rollup fields that depend on the field, detaching them from * their owner collections. - * 2. For a relation field, detaches both sides of the pair from their + * 2. Drops formula fields that depend on the field, detaching them from + * their owner collections. + * 3. For a relation field, detaches both sides of the pair from their * owner collections, drops their dependent rollups, and deletes the * reverse field (guarded against the reverse delete bouncing back). - * 3. Removes the `field-` value meta from every row. - * 4. Removes the field's string ID from every collection's + * 4. Removes the `field-` value meta from every row. + * 5. Removes the field's string ID from every collection's * `cortext_fields` schema list. * * @param int $post_id Post being deleted. @@ -169,6 +171,7 @@ public function cleanup_after_delete( int $post_id, ?WP_Post $post = null ): voi } $this->delete_dependent_rollups( $post_id ); + $this->delete_dependent_formulas( $post_id ); $reverse_id = (int) get_post_meta( $post_id, 'relation_reverse_field_id', true ); if ( $reverse_id > 0 && empty( self::$deleting_relation_fields[ $reverse_id ] ) ) { @@ -177,6 +180,7 @@ public function cleanup_after_delete( int $post_id, ?WP_Post $post = null ): voi $this->detach_from_collections( $post_id ); $this->detach_from_collections( $reverse_id ); $this->delete_dependent_rollups( $reverse_id ); + $this->delete_dependent_formulas( $reverse_id ); self::$deleting_relation_fields[ $post_id ] = true; wp_delete_post( $reverse_id, true ); @@ -273,6 +277,63 @@ private function delete_dependent_rollups( int $field_id ): void { } } + /** + * Deletes formula fields that depend on a deleted field. A missing + * dependency leaves the compiled AST orphaned, so the dependent formula + * column goes away with the source field. + * + * @param int $field_id Field post ID being deleted. + */ + private function delete_dependent_formulas( int $field_id ): void { + $formula_ids = get_posts( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => array( 'draft', 'private', 'publish' ), + 'fields' => 'ids', + 'posts_per_page' => -1, + 'no_found_rows' => true, + 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + array( + 'key' => 'type', + 'value' => 'formula', + 'compare' => '=', + ), + ), + ) + ); + + foreach ( array_map( 'intval', $formula_ids ) as $formula_id ) { + if ( + $formula_id === $field_id + || self::POST_TYPE !== get_post_type( $formula_id ) + || 'formula' !== (string) get_post_meta( $formula_id, 'type', true ) + || ! in_array( $field_id, $this->formula_dependency_ids( $formula_id ), true ) + ) { + continue; + } + $this->detach_from_collections( $formula_id ); + wp_delete_post( $formula_id, true ); + } + } + + /** + * Reads the stored dependency IDs for a formula field. + * + * @param int $formula_id Formula field post ID. + * @return int[] + */ + private function formula_dependency_ids( int $formula_id ): array { + $raw = (string) get_post_meta( $formula_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 ) ) ); + } + public function register_post_type(): void { register_post_type( self::POST_TYPE, @@ -338,6 +399,40 @@ 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, + ), + 'dep_field_ids' => array( + 'type' => 'array', + 'readonly' => true, + 'items' => array( + 'type' => 'integer', + ), + ), + ), + ), + ) + ); } /** @@ -350,7 +445,47 @@ 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 ), + 'dep_field_ids' => $this->formula_dependency_ids( $field_id ), + ); + } + + /** + * Normalizes formula text without treating operators like HTML. + * + * `sanitize_textarea_field()` corrupts `<`, `<=`, `>`, and quoted strings + * before the compiler sees them. The lexer and compiler handle formula + * safety; this callback only removes invalid UTF-8, null bytes, and + * platform-specific line endings. + * + * @param mixed $expression Raw formula text. + */ + public static function sanitize_formula_expression( mixed $expression ): string { + $value = wp_check_invalid_utf8( (string) $expression ); + $value = str_replace( array( "\r\n", "\r" ), "\n", $value ); + $value = str_replace( "\0", '', $value ); + return trim( $value ); } private function register_meta(): void { @@ -359,7 +494,6 @@ private function register_meta(): void { 'options', 'number_format', 'date_format', - 'expression', 'rollup_aggregator', 'rollup_target_type', 'rollup_target_options', @@ -391,6 +525,17 @@ private function register_meta(): void { ) ); + register_post_meta( + self::POST_TYPE, + 'expression', + array( + 'type' => 'string', + 'single' => true, + 'show_in_rest' => false, + 'sanitize_callback' => array( self::class, 'sanitize_formula_expression' ), + ) + ); + register_post_meta( self::POST_TYPE, FieldDefaults::META_KEY, @@ -402,6 +547,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 +619,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..e73e644f 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', + __( 'We couldn\'t 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' ), + __( 'We couldn\'t find this field\'s collection.', 'cortext' ), array( 'status' => 400 ) ); } @@ -626,13 +726,6 @@ private function collect_option_tokens( int $field_id, string $target_type, ?arr return array_keys( $seen ); } - /** - * Finds the entry post type for the collection that owns this field. - * - * A field belongs to one collection, so the first match is enough. - * - * @param int $field_id Field post ID to resolve. - */ /** * Resolves the mirror term id for the trait that owns the given field, * or 0 when the field is not attached to any trait. @@ -640,10 +733,16 @@ private function collect_option_tokens( int $field_id, string $target_type, ?arr * @param int $field_id Field post id. */ private function trait_term_id_for_field( int $field_id ): int { + $collection_id = $this->collection_id_for_field( $field_id ); + if ( $collection_id < 1 ) { + return 0; + } + 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; - // Reverse lookup: which collection's `cortext_fields` meta references - // this field? global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- field→collection reverse lookup; bounded by a single matching row. $collection_id = (int) $wpdb->get_var( @@ -653,10 +752,29 @@ private function trait_term_id_for_field( int $field_id ): int { $field_id_str ) ); - if ( $collection_id < 1 ) { - return 0; + if ( $collection_id > 0 ) { + return $collection_id; } - return Relations::trait_term_id_for_collection( $collection_id ); + + if ( self::is_wordbless_active() ) { + foreach ( \WorDBless\PostMeta::init()->meta as $post_id => $rows ) { + foreach ( $rows as $row ) { + if ( + isset( $row['meta_key'], $row['meta_value'] ) && + 'cortext_fields' === $row['meta_key'] && + (string) maybe_unserialize( $row['meta_value'] ) === $field_id_str + ) { + return (int) $post_id; + } + } + } + } + + return 0; + } + + private static function is_wordbless_active(): bool { + return class_exists( '\WorDBless\PostMeta' ); } /** @@ -948,6 +1066,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 Field::sanitize_formula_expression( $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. */ + __( 'Changing this would break "%1$s": %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..45c21bae 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,11 @@ 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 ) . ' )' : ''; + // tech-debt.md#td-formula-materialized-values: volatile formula sort/filter reads materialized meta. + 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 +273,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 +971,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 +985,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 +1048,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 +1240,61 @@ 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 ) + ); + } + + private function field_id_from_key( string $field_key ): int { + if ( 1 !== preg_match( '/^field-(\d+)$/', $field_key, $matches ) ) { + return 0; + } + return (int) $matches[1]; + } + /** * Reads valid values for a select or multiselect field. * @@ -1328,7 +1407,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 +1420,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, 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..4e9a4f2f 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( @@ -115,6 +121,34 @@ export default function FieldActionsMenu( { candidate.rollupTargetFieldId === recordId ) ); }, [ fields, recordId ] ); + const dependentFormulas = useMemo( () => { + const fieldList = Array.isArray( fields ) ? fields : []; + const deletedIds = new Set( [ Number( recordId ) ] ); + const dependents = []; + let found = true; + while ( found ) { + found = false; + for ( const candidate of fieldList ) { + const candidateId = Number( candidate.recordId ); + if ( + ! candidateId || + deletedIds.has( candidateId ) || + candidate.cortextType !== 'formula' + ) { + continue; + } + const deps = Array.isArray( candidate.formulaDepFieldIds ) + ? candidate.formulaDepFieldIds.map( Number ) + : []; + if ( deps.some( ( depId ) => deletedIds.has( depId ) ) ) { + dependents.push( candidate ); + deletedIds.add( candidateId ); + found = true; + } + } + } + return dependents; + }, [ fields, recordId ] ); const cancelClose = useCallback( () => { if ( closeTimerRef.current ) { @@ -370,6 +404,18 @@ export default function FieldActionsMenu( { ) : null } + { supportsFormula ? ( + { + setFormulaError( '' ); + setIsEditingFormula( true ); + } } + > + + { __( 'Edit formula', 'cortext' ) } + + + ) : null } { canChangeType ? ( setIsChangingType( true ) } @@ -443,13 +489,13 @@ export default function FieldActionsMenu( {

{ dependentRollups.length === 1 ? __( - 'This will also delete 1 rollup that depends on it:', + 'This also deletes 1 rollup that uses it:', 'cortext' ) : sprintf( /* translators: %d: number of dependent rollup fields */ __( - 'This will also delete %d rollups that depend on it:', + 'This also deletes %d rollups that use it:', 'cortext' ), dependentRollups.length @@ -459,6 +505,26 @@ export default function FieldActionsMenu( { .join( ', ' ) }

) : null } + { dependentFormulas.length > 0 ? ( +

+ { dependentFormulas.length === 1 + ? __( + 'This also deletes 1 formula that uses it:', + 'cortext' + ) + : sprintf( + /* translators: %d: number of dependent formula fields */ + __( + 'This also deletes %d formulas that use it:', + 'cortext' + ), + dependentFormulas.length + ) }{ ' ' } + { dependentFormulas + .map( ( candidate ) => candidate.label ) + .join( ', ' ) } +

+ ) : null } ) : null } { isEditingOptions && supportsOptions ? ( @@ -481,6 +547,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 value from this row.', 'cortext' ), + }, + { + label: 'prop', + signature: 'prop("Name")', + insertText: 'prop("")', + caretOffset: 'prop("'.length, + type: 'alias', + description: __( 'Works the same as field().', 'cortext' ), + }, + { + label: 'concat', + signature: 'concat(value, ...)', + insertText: 'concat()', + caretOffset: 'concat('.length, + type: 'text', + description: __( 'Join values.', '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: __( 'Make text uppercase.', 'cortext' ), + }, + { + label: 'lower', + signature: 'lower(text)', + insertText: 'lower()', + caretOffset: 'lower('.length, + type: 'text', + description: __( 'Make text 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 one of two values.', 'cortext' ), + }, + { + label: 'now', + signature: 'now()', + insertText: 'now()', + caretOffset: 'now()'.length, + type: 'datetime', + description: __( 'Current date and time.', 'cortext' ), + }, + { + label: 'dateBetween', + signature: 'dateBetween(a, b, "days")', + insertText: 'dateBetween(, , "days")', + caretOffset: 'dateBetween('.length, + type: 'number', + description: __( 'Difference between two dates.', 'cortext' ), + }, + { + label: 'formatDate', + signature: 'formatDate(date, format)', + insertText: 'formatDate(, "YYYY-MM-DD")', + caretOffset: 'formatDate('.length, + type: 'text', + description: __( 'Format a date as text.', '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 || + __( "We couldn't save the formula.", 'cortext' ) + ); + } + }; + + return ( +
+ { errorMessage ? ( + + { errorMessage } + + ) : null } +
+
+ + +
+
+ +