ready(); TransactionGuard::prepare_write(); return $operation(); } finally { if ( $owned ) { BookingLock::release(); } } } public function transaction( callable $operation ) { global $wpdb; if ( ! BookingLock::is_held() || self::$active ) { throw new \RuntimeException( 'import_owner' ); } $started = false; $suppression = $wpdb->suppress_errors( true ); try { TransactionGuard::prepare_write(); $this->write( $wpdb->query( 'START TRANSACTION' ) ); $started = true; self::$active = true; ++self::$generation; self::clear_link_metadata(); $result = $operation(); $this->write( $wpdb->query( 'COMMIT' ) ); $started = false; return $result; } catch ( \Throwable $error ) { if ( $started ) { $wpdb->query( 'ROLLBACK' ); } throw $error; } finally { self::$active = false; self::clear_link_metadata(); $wpdb->suppress_errors( $suppression ); } } /** Only schema metadata is cached, and only within the concrete private transaction generation. * A fresh bounded inventory detects newly added/altered unknown stores before any cache reuse. * Canonical/dependent rows and required ENGINE checks are deliberately outside this cache. */ public function appointment_link_metadata(): array { global $wpdb; $prefix = $wpdb->prefix . 'abientot_'; $inventory = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT TABLE_SCHEMA,TABLE_NAME,COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE,EXTRA,ORDINAL_POSITION FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME LIKE %s ORDER BY TABLE_NAME,ORDINAL_POSITION LIMIT 5001', $wpdb->esc_like( $prefix ) . '%' ), ARRAY_A ), 'import_storage' ); if ( count( $inventory ) > 5000 ) { self::clear_link_metadata(); throw new \RuntimeException( 'import_storage' ); } $handle = $wpdb->dbh; $connection = is_object( $handle ) ? (string) spl_object_id( $handle ) : ( is_resource( $handle ) ? (string) (int) $handle : 'none' ); if ( $handle instanceof \mysqli ) { $connection .= '|' . (string) $handle->thread_id; } $context = self::$generation . '|' . $connection . '|' . $prefix; $digest = hash( 'sha256', (string) wp_json_encode( $inventory ) ); if ( self::owned() && self::$linkMetadata !== null && self::$metadataContext === $context && hash_equals( self::$metadataDigest, $digest ) ) { return self::$linkMetadata; } $tables = array(); foreach ( $inventory as $column ) { $table = (string) $column['TABLE_NAME']; if ( ! preg_match( '/^[A-Za-z0-9_]+$/D', $table ) || ! str_starts_with( $table, $prefix ) ) { self::clear_link_metadata(); throw new \RuntimeException( 'import_storage' ); } if ( ! isset( $tables[ $table ] ) ) { $tables[ $table ] = array( 'name' => substr( $table, strlen( $prefix ) ), 'columns' => array() ); } $tables[ $table ]['columns'][] = (string) $column['COLUMN_NAME']; } if ( count( $tables ) > 100 ) { self::clear_link_metadata(); throw new \RuntimeException( 'import_storage' ); } if ( self::owned() ) { self::$linkMetadata = $tables; self::$metadataContext = $context; self::$metadataDigest = $digest; } return $tables; } private function write( $value ): void { if ( $value === false ) { throw new \RuntimeException( 'import_write' ); } } public function insert( string $name, array $data, bool $identity = true ): int { global $wpdb; if ( ! self::owned() || ! in_array( $name, array( 'customers', 'services', 'staff_services', 'import_batches', 'import_rows' ), true ) ) { throw new \RuntimeException( 'import_owner' ); } $this->write( $wpdb->insert( $this->table( $name ), $data ) ); $id = (int) $wpdb->insert_id; if ( $identity && $id < 1 ) { throw new \RuntimeException( 'import_write' ); } return $id; } public function update( string $name, array $data, array $where ): void { global $wpdb; if ( ! self::owned() || ! in_array( $name, array( 'import_batches', 'import_rows' ), true ) ) { throw new \RuntimeException( 'import_owner' ); } $this->write( $wpdb->update( $this->table( $name ), $data, $where ) ); } public function find( string $name, int $id ): ?array { global $wpdb; if ( ! in_array( $name, array( 'customers', 'services', 'staff', 'locations', 'appointments' ), true ) || $id < 1 ) { throw new \DomainException( 'import_reference' ); } $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE id=%d', $this->table( $name ), $id ), ARRAY_A ), 'import_storage' ); return $rows[0] ?? null; } public function choices( string $name ): array { global $wpdb; if ( ! in_array( $name, array( 'services', 'staff', 'locations' ), true ) ) { throw new \DomainException( 'import_reference' ); } return Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY id', $this->table( $name ) ), ARRAY_A ), 'import_storage' ); } public function contacts( string $email, string $phone ): array { global $wpdb; if ( $email === '' && $phone === '' ) { return array(); } $lower = strtolower( $email ); return Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE ( %s <> '' AND LOWER(email)=%s ) OR ( %s <> '' AND REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(phone,' ',''),'-',''),'(',''),')',''),'.','')=%s ) ORDER BY id LIMIT 3", $this->table( 'customers' ), $email, $lower, $phone, $phone ), ARRAY_A ), 'import_storage' ); } public function slug( string $slug ): array { global $wpdb; return Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE slug=%s ORDER BY id LIMIT 3', $this->table( 'services' ), $slug ), ARRAY_A ), 'import_storage' ); } public function source( string $hash ): ?array { global $wpdb; $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT batch_uuid,state FROM %i WHERE source_hash=%s', $this->table( 'import_rows' ), $hash ), ARRAY_A ), 'import_storage' ); return $rows[0] ?? null; } public function batch( string $uuid ): ?array { global $wpdb; $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE batch_uuid=%s', $this->table( 'import_batches' ), $uuid ), ARRAY_A ), 'import_storage' ); return $rows[0] ?? null; } public function batch_rows( string $uuid ): array { global $wpdb; return Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE batch_uuid=%s ORDER BY ordinal LIMIT 101', $this->table( 'import_rows' ), $uuid ), ARRAY_A ), 'import_storage' ); } public function owned_revision( string $kind, int $id ): string { $row = $this->find( $kind, $id ); if ( ! $row ) { return ''; } if ( $kind === 'appointments' && class_exists( __NAMESPACE__ . '\\AppointmentAdapter', false ) ) { return ( new AppointmentAdapter( $this ) )->revision( $id ); } $links = $kind === 'services' ? $this->service_links( $id ) : array(); return Token::digest( array( 'row' => $row, 'links' => $links ) ); } private function service_links( int $id ): array { global $wpdb; return Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE service_id=%d ORDER BY id', $this->table( 'staff_services' ), $id ), ARRAY_A ), 'import_storage' ); } /** Refuse referenced rows rather than cascade data owned by another feature. */ public function removable( string $kind, int $id, string $revision, array $owned_appointments = array() ): bool { global $wpdb; if ( $this->owned_revision( $kind, $id ) !== $revision ) { return false; } $column = $kind === 'customers' ? 'customer_id' : 'service_id'; if ( count( $owned_appointments ) > 100 ) { return false; } if ( $owned_appointments ) { $found = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT id FROM %i WHERE %i=%d AND id NOT IN (' . implode( ',', array_fill( 0, count( $owned_appointments ), '%d' ) ) . ') LIMIT 1', array_merge( array( $this->table( 'appointments' ), $column, $id ), $owned_appointments ) ), ARRAY_A ), 'import_storage' ); } else { $found = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT id FROM %i WHERE %i=%d LIMIT 1', $this->table( 'appointments' ), $column, $id ), ARRAY_A ), 'import_storage' ); } if ( $found ) { return false; } if ( $kind === 'customers' ) { $read = new \Abientot\Repositories\ResourceCapacityRepository(); foreach ( array( 'waitlist_entries', 'waitlist_preferences' ) as $table ) { if ( $read->exists( $table ) && Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT customer_id FROM %i WHERE customer_id=%d LIMIT 1', $this->table( $table ), $id ), ARRAY_A ), 'import_storage' ) ) { return false; } } } if ( $kind === 'services' ) { $read = new \Abientot\Repositories\ResourceCapacityRepository(); foreach ( array( 'service_selection_definitions', 'service_resources', 'group_sessions' ) as $table ) { if ( $read->exists( $table ) && Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT service_id FROM %i WHERE service_id=%d LIMIT 1', $this->table( $table ), $id ), ARRAY_A ), 'import_storage' ) ) { return false; } } } $object = $kind === 'customers' ? 'customer' : 'service'; if ( Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT id FROM %i WHERE object_type=%s AND object_id=%d LIMIT 1', $this->table( 'activity_log' ), $object, $id ), ARRAY_A ), 'import_storage' ) ) { return false; } // Optional and unknown stores with concrete subject links are factual dependencies. foreach ( $this->appointment_link_metadata() as $table => $metadata ) { $name = $metadata['name']; if ( $name === 'appointments' || ( $kind === 'services' && $name === 'staff_services' ) ) { continue; } $columns = $metadata['columns']; if ( in_array( $column, $columns, true ) && Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT 1 FROM %i WHERE %i=%d LIMIT 1', $table, $column, $id ), ARRAY_A ), 'import_storage' ) ) { return false; } foreach ( array( 'owner', 'object' ) as $prefix ) { if ( in_array( $prefix . '_type', $columns, true ) && in_array( $prefix . '_id', $columns, true ) && Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT 1 FROM %i WHERE %i=%s AND %i=%d LIMIT 1', $table, $prefix . '_type', $object, $prefix . '_id', $id ), ARRAY_A ), 'import_storage' ) ) { return false; } } } if ( $kind === 'services' && ( new \Abientot\Repositories\ResourceCapacityRepository() )->exists( 'credit_packages' ) ) { $packages = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT service_ids_json FROM %i LIMIT 1001', $this->table( 'credit_packages' ) ), ARRAY_A ), 'import_storage' ); if ( count( $packages ) > 1000 ) { return false; } foreach ( $packages as $package ) { $services = json_decode( $package['service_ids_json'], true, 16, JSON_THROW_ON_ERROR ); if ( ! is_array( $services ) ) { return false; } foreach ( $services as $service ) { if ( ( ! is_int( $service ) && ! is_string( $service ) ) || (int) $service === $id ) { return false; } } } } // Additional customer/service records must explicitly veto rollback through a pure filter. return true === apply_filters( 'abientot_import_record_removable', true, $kind, $id ); } public function remove( string $kind, int $id ): void { global $wpdb; if ( ! self::owned() || ! in_array( $kind, array( 'customers', 'services' ), true ) ) { throw new \RuntimeException( 'import_owner' ); } if ( $kind === 'services' ) { $this->write( $wpdb->delete( $this->table( 'staff_services' ), array( 'service_id' => $id ) ) ); } $this->write( $wpdb->delete( $this->table( $kind ), array( 'id' => $id ) ) ); } }