Formats keyed by column name. */ protected function formats(): array { return array( 'form_id' => '%d', 'field_key' => '%s', 'type' => '%s', 'label' => '%s', 'placeholder' => '%s', 'description' => '%s', 'default_value' => '%s', 'options' => '%s', 'settings' => '%s', 'is_required' => '%d', 'menu_order' => '%d', 'created_at' => '%s', 'updated_at' => '%s', ); } /** * Returns the fields of a form, in display order. * * @param int $form_id Form ID. * * @return array The fields. */ public function for_form( int $form_id ): array { if ( $form_id <= 0 ) { return array(); } $query = $this->query_builder() ->where_equals( 'form_id', $form_id, '%d' ) ->order_by( 'menu_order', 'ASC', self::SORTABLE, 'menu_order' ) ->then_by( 'id', 'ASC', self::SORTABLE ); return $this->hydrate( $this->select( $query ), static fn( array $row ): Field => Field::from_row( $row ) ); } /** * Finds a single field. * * @param int $id Field ID. * * @return Field|null The field, or null when it does not exist. */ public function find( int $id ): ?Field { $row = $this->find_row( $id ); return null === $row ? null : Field::from_row( $row ); } /** * Replaces the field set of a form with the submitted one. * * Fields carrying a known ID are updated, new entries are inserted, and rows * that are no longer present are deleted. That single operation covers add, * edit, delete and reorder, so the builder never needs a separate endpoint * per action. * * @param int $form_id Form the fields belong to. * @param array> $fields Sanitised field data, in display order. * * @return array IDs of the stored fields, in display order. */ public function sync( int $form_id, array $fields ): array { if ( $form_id <= 0 ) { return array(); } $existing = $this->keys_for_form( $form_id ); /* * The whole reconcile is one transaction. It renames keys, rewrites rows, * inserts, deletes and repoints stored answers; a failure halfway through * used to leave the field set and the answers describing different forms. */ $kept = $this->transaction( fn(): array|false => $this->reconcile( $form_id, $fields, $existing ) ); if ( ! is_array( $kept ) ) { return array(); } /** * Fires after the field set of a form has been stored. * * @since 1.0.0 * * @param int $form_id Form ID. * @param array $kept IDs of the stored fields, in display order. */ do_action( 'formpilot_fields_synced', $form_id, $kept ); return $kept; } /** * Applies a submitted field set to the stored one. * * Runs inside {@see AbstractRepository::transaction()} and therefore must not * open one of its own — MySQL treats a second `START TRANSACTION` as an * implicit commit of the first, which would defeat the point. * * @param int $form_id Form the fields belong to. * @param array> $fields Sanitised field data, in display order. * @param array $existing Stored field keys, keyed by field ID. * * @return array|false IDs of the stored fields in display order, or false to roll back. */ private function reconcile( int $form_id, array $fields, array $existing ): array|false { $now = $this->now(); $kept = array(); $keys = array(); $plan = array(); /* * Pass one settles which stored row each submitted field is, and reserves * the keys of rows that are being preserved rather than rewritten. * * The reservation has to happen before *any* key is allocated. Done inline, * a preserved field appearing late in the order had not reserved its key * yet when an earlier field asked for one, so both ended up wanting it and * the later write collided on the unique index. */ foreach ( array_values( $fields ) as $order => $field ) { $id = isset( $field['id'] ) ? absint( $field['id'] ) : 0; // An ID that does not belong to this form is treated as a new field. if ( ! array_key_exists( $id, $existing ) ) { $id = 0; } /* * A field the sanitiser could not interpret — an unknown type, because * the add-on that registered it is not loaded. It keeps its stored * definition and its key; only its position is taken from the request. * A *new* field of an unknown type has nothing to preserve. */ $preserve = ! empty( $field['preserve'] ); if ( $preserve && $id <= 0 ) { continue; } if ( $preserve ) { $keys[] = $existing[ $id ]; } $plan[] = array( 'id' => $id, 'key' => $preserve ? $existing[ $id ] : '', 'order' => $order, 'field' => $field, 'preserve' => $preserve, ); } /* * Pass two allocates the keys. Uniqueness is settled against the batch * alone, not against the table: every stored row is about to be rewritten, * preserved or deleted by this same reconcile, so the only keys genuinely * reserved are the preserved ones collected above. Asking the database * instead used to cost one COUNT per collision candidate and still got a * straight key swap wrong. */ foreach ( $plan as $index => $entry ) { if ( $entry['preserve'] ) { continue; } $key = $this->unique_field_key( (string) ( $entry['field']['field_key'] ?? '' ), (string) ( $entry['field']['label'] ?? '' ), $keys ); $keys[] = $key; $plan[ $index ]['key'] = $key; } /* * Removal comes first, and that ordering is load-bearing. * * `(form_id, field_key)` is unique, so a row still holding a key is a row * still occupying it. Deleting last meant the commonest edit there is — * remove a field and add another that derives the same key, or rename a * field onto a key being freed in the same save — collided on the index, * failed the insert and rolled the *entire* save back. Nothing was stored * and, because the answers now survive deletion, nothing warned anybody. */ $planned = array(); foreach ( $plan as $entry ) { if ( $entry['id'] > 0 ) { $planned[] = $entry['id']; } } foreach ( array_diff( array_keys( $existing ), $planned ) as $removed ) { if ( ! $this->remove_field( (int) $removed ) ) { return false; } } if ( ! $this->park_renamed_keys( $plan, $existing ) ) { return false; } foreach ( $plan as $entry ) { $id = (int) $entry['id']; $field = $entry['field']; if ( $entry['preserve'] ) { if ( ! $this->update_row( $id, array( 'menu_order' => (int) $entry['order'], 'updated_at' => $now ) ) ) { return false; } $kept[] = $id; continue; } $columns = array( 'form_id' => $form_id, 'field_key' => $entry['key'], 'type' => (string) ( $field['type'] ?? '' ), 'label' => (string) ( $field['label'] ?? '' ), // varchar(255): wpdb refuses a longer value, which rolled back the whole save. 'placeholder' => mb_substr( (string) ( $field['placeholder'] ?? '' ), 0, 255 ), 'description' => (string) ( $field['description'] ?? '' ), 'default_value' => (string) ( $field['default_value'] ?? '' ), 'options' => (string) wp_json_encode( $field['options'] ?? array() ), 'settings' => (string) wp_json_encode( $field['settings'] ?? array() ), 'is_required' => empty( $field['is_required'] ) ? 0 : 1, 'menu_order' => (int) $entry['order'], 'updated_at' => $now, ); if ( $id > 0 ) { if ( ! $this->update_row( $id, $columns ) ) { return false; } // Carry the stored answers across a rename, or the column empties. if ( $existing[ $id ] !== $entry['key'] && ! $this->rename_answers( $id, $entry['key'] ) ) { return false; } $kept[] = $id; continue; } $columns['created_at'] = $now; $inserted = $this->insert_row( $columns ); if ( $inserted <= 0 ) { return false; } $kept[] = $inserted; } return $kept; } /** * Moves every field whose key is changing onto a throwaway key first. * * `(form_id, field_key)` is unique, so swapping two keys in place collides on * whichever row is written first. Parking the changing rows under names that * cannot collide makes the order irrelevant. * * @param array> $plan Planned writes. * @param array $existing Stored field keys, keyed by field ID. * * @return bool False when a write failed and the caller should roll back. */ private function park_renamed_keys( array $plan, array $existing ): bool { $renamed = array(); foreach ( $plan as $entry ) { $id = (int) $entry['id']; if ( $id > 0 && $existing[ $id ] !== $entry['key'] ) { $renamed[] = $id; } } if ( array() === $renamed ) { return true; } $token = strtolower( wp_generate_password( 12, false, false ) ); foreach ( $renamed as $id ) { if ( ! $this->update_row( $id, array( 'field_key' => '_formpilot' . $token . $id ) ) ) { return false; } } return true; } /** * Repoints stored answers at a field's new key. * * `formpilot_submission_meta.meta_key` is what exports and the entry screen read by, * so leaving it on the old key turns every historical answer into a blank * column that nothing can reach. * * @param int $field_id Field whose answers move. * @param string $key New field key. * * @return bool True when the write succeeded. */ private function rename_answers( int $field_id, string $key ): bool { if ( $field_id <= 0 || '' === $key ) { return false; } $meta = Schema::table( Schema::SUBMISSION_META ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Cross table write; table name comes from the schema. $updated = $this->db->query( $this->db->prepare( "UPDATE {$meta} SET meta_key = %s WHERE field_id = %d", $key, $field_id ) ); if ( false === $updated ) { $this->report_error( 'update' ); return false; } return true; } /** * Deletes a field, keeping the answers already collected through it. * * @param int $id Field ID. * * @return bool True when the field was removed. */ public function delete( int $id ): bool { if ( $id <= 0 ) { return false; } return (bool) $this->transaction( fn(): bool => $this->remove_field( $id ) ); } /** * Removes one field row and detaches its answers. * * Deleting a field is a change to the form, not to the record of what people * already sent: the answers stay, with `field_id` cleared so nothing points at * a row that no longer exists. A site that genuinely wants the answers gone * says so through `formpilot_delete_field_answers`; the erasure is then explicit and * auditable rather than a side effect of editing a form. * * Must be called from inside a transaction — see {@see FieldRepository::reconcile()}. * * @param int $id Field ID. * * @return bool True when the field was removed. */ private function remove_field( int $id ): bool { $meta = Schema::table( Schema::SUBMISSION_META ); /** * Filters whether deleting a field also deletes the answers collected through it. * * @since 1.0.0 * * @param bool $purge Whether to delete the stored answers. Default false. * @param int $id Field being deleted. */ $purge = (bool) apply_filters( 'formpilot_delete_field_answers', false, $id ); if ( $purge ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Cross table cleanup; table name comes from the schema. $detached = $this->db->query( $this->db->prepare( "DELETE FROM {$meta} WHERE field_id = %d", $id ) ); } else { /* * The key is tombstoned as well as the link. * * Everything downstream — exports, the entry screen — reads answers by * `meta_key` alone. Leaving a detached row under its original key means * a field created later with the same key silently inherits the deleted * field's answers and shows them as its own; and if an existing field is * then renamed onto that key, one submission ends up with two rows * sharing it, which `meta()` collapses arbitrarily. The prefix keeps the * answer readable and stops it being mistaken for a live one. * * `field_key` is varchar(100) and `meta_key` is far wider, so the prefix * cannot truncate. Two fields of one form can never share a key, so two * tombstones of one submission can never collide either. */ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Cross table cleanup; table name comes from the schema. $detached = $this->db->query( $this->db->prepare( "UPDATE {$meta} SET field_id = 0, meta_key = CONCAT( %s, meta_key ) WHERE field_id = %d AND meta_key NOT LIKE %s", self::DELETED_PREFIX, $id, $this->db->esc_like( self::DELETED_PREFIX ) . '%' ) ); } if ( false === $detached ) { $this->report_error( $purge ? 'delete' : 'update' ); return false; } return $this->delete_row( $id ); } /** * Copies every field of a form onto another form. * * @param int $source_form_id Form to copy from. * @param int $target_form_id Form to copy to. */ public function copy_to_form( int $source_form_id, int $target_form_id ): int { if ( $source_form_id <= 0 || $target_form_id <= 0 || $source_form_id === $target_form_id ) { return 0; } $fields = $this->for_form( $source_form_id ); if ( array() === $fields ) { return 0; } $payload = array(); foreach ( $fields as $field ) { $data = $field->to_array(); $data['id'] = 0; $payload[] = $data; } return count( $this->sync( $target_form_id, $payload ) ); } /** * Returns the field keys currently stored for a form, keyed by field ID. * * @param int $form_id Form ID. * * @return array Field keys keyed by ID. */ private function keys_for_form( int $form_id ): array { $rows = $this->select( $this->query_builder()->where_equals( 'form_id', $form_id, '%d' ), 'id, field_key' ); $keys = array(); foreach ( $rows as $row ) { $keys[ (int) $row['id'] ] = (string) $row['field_key']; } return $keys; } /** * Builds a field key that is unique within the submitted set. * * Only the batch is consulted: {@see FieldRepository::reconcile()} rewrites or * deletes every stored row of the form in the same transaction, so no stored * key survives to collide with. The key is also capped at the column width — * `field_key` is `varchar(100)` and a longer value would be truncated by the * database, silently turning two distinct keys into one. * * @param string $key Requested key. * @param string $fallback Label used when the key is empty. * @param array $taken Keys already assigned in this batch. */ private function unique_field_key( string $key, string $fallback, array $taken ): string { $base = sanitize_key( '' !== $key ? $key : sanitize_title( $fallback ) ); if ( '' === $base ) { $base = 'field'; } $base = mb_substr( $base, 0, self::KEY_MAX_LENGTH ); $candidate = $base; $suffix = 2; while ( in_array( $candidate, $taken, true ) ) { $tail = '-' . $suffix; $candidate = mb_substr( $base, 0, self::KEY_MAX_LENGTH - mb_strlen( $tail ) ) . $tail; ++$suffix; } return $candidate; } }