Files
peptide-repo-core/includes/migrations/class-pr-core-migration-0004-backfill-peptide-meta.php
peptiderepo 665d7fa9e1 Adopt reusable CI standard + PHPUnit conversion (#22)
Squash-merge of PR #22: adopt shared reusable CI workflow, convert PHPUnit to stubs, update branch-protection contexts.

QA-approved at 05d8094a1d. All required CI checks green.

Agent-Session: 2026-06-17-prcore-ci-deploy
2026-06-17 01:12:00 +07:00

294 lines
9.8 KiB
PHP

<?php
/**
* Migration 0004: Backfill peptide schema meta.
*
* @package Peptide_Repo_Core
*/
declare(strict_types=1);
/**
* Migration 0004: Backfill peptide schema meta from PSA psa_* keys.
*
* What: Copies PSA-stored PubChem data (molecular formula, weight, aliases)
* into PR Core's own _pr_molecular_formula, _pr_molecular_weight,
* _pr_aliases meta keys on all published peptide posts.
* For posts missing PSA data, attempts a PubChem REST lookup via
* PR_Core_Pubchem_Client.
*
* Who calls it: PR_Core_Migration_Runner::run_pending().
* Dependencies: WordPress get_post_meta(), update_post_meta(), get_posts().
* PR_Core_Pubchem_Client for PubChem fallback.
* PR_Core_Schema_Sanitizers for formula sanitization.
* PR_Core_Peptide_CPT::POST_TYPE for query.
*
* Idempotent: skips posts where all three PR Core meta keys are already non-empty.
* Re-runnable: safe to run multiple times.
*
* PSA source keys (all treated as UNTRUSTED — sanitized before writing):
* psa_molecular_formula => _pr_molecular_formula (string)
* psa_molecular_weight => _pr_molecular_weight (float string, strip "Da")
* psa_aliases => _pr_aliases (JSON array string)
*
* @see ARCHITECTURE.md — §v0.6.0 schema sprint.
* @see CONVENTIONS.md — How To: Add a New Migration.
* @see class-pr-core-pubchem-client.php — HTTP client for PubChem REST.
*/
class PR_Core_Migration_0004_Backfill_Peptide_Meta {
/** @var string Meta key: molecular formula (PR Core namespace). */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
public const META_FORMULA = '_pr_molecular_formula';
/** @var string Meta key: molecular weight as float string (PR Core namespace). */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
public const META_WEIGHT = '_pr_molecular_weight';
/** @var string Meta key: aliases JSON array (PR Core namespace). */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
public const META_ALIASES = '_pr_aliases';
/**
* HTTP client for PubChem REST API.
*
* @var PR_Core_Pubchem_Client
*/
private PR_Core_Pubchem_Client $pubchem;
/**
* Construct migration with optional PubChem client (injectable for testing).
*
* @param PR_Core_Pubchem_Client|null $pubchem PubChem client; creates default if null.
*/
public function __construct( ?PR_Core_Pubchem_Client $pubchem = null ) {
$this->pubchem = $pubchem ?? new PR_Core_Pubchem_Client();
}
/**
* Run the backfill migration.
*
* Side effects: reads post meta, writes post meta, logs via error_log.
*
* @return void
*/
public function up(): void {
$peptide_ids = $this->get_all_peptide_ids();
if ( empty( $peptide_ids ) ) {
error_log( '[PR Core Migration 0004] No peptide posts found — nothing to backfill.' );
return;
}
$counts = array(
'skipped' => 0,
'copied_psa' => 0,
'pubchem_ok' => 0,
'pubchem_skip' => 0,
);
foreach ( $peptide_ids as $post_id ) {
$result = $this->backfill_post( $post_id );
$counts[ $result ] += 1;
}
error_log(
sprintf(
'[PR Core Migration 0004] Complete. Skipped: %d, PSA-copied: %d, PubChem-ok: %d, PubChem-skip: %d',
$counts['skipped'],
$counts['copied_psa'],
$counts['pubchem_ok'],
$counts['pubchem_skip']
)
);
}
/**
* Backfill meta for a single peptide post.
*
* @param int $post_id WordPress post ID.
* @return string Result: skipped|copied_psa|pubchem_ok|pubchem_skip.
*/
private function backfill_post( int $post_id ): string {
$existing_formula = (string) get_post_meta( $post_id, self::META_FORMULA, true );
$existing_weight = (string) get_post_meta( $post_id, self::META_WEIGHT, true );
$existing_aliases = (string) get_post_meta( $post_id, self::META_ALIASES, true );
// Idempotency limitation: this guard is all-or-nothing. A peptide that legitimately.
// has no aliases (e.g., a novel compound) will never satisfy all three conditions and.
// the migration will re-run on every manual re-invoke. This is safe because.
// write_meta_from_psa() / write_meta_from_pubchem() only overwrite with non-empty.
// values, but callers should be aware the 'skipped' result requires all three keys set.
if ( '' !== $existing_formula && '' !== $existing_weight && '' !== $existing_aliases ) {
return 'skipped';
}
$psa_formula = (string) get_post_meta( $post_id, 'psa_molecular_formula', true );
if ( '' !== $psa_formula ) {
$psa_weight = (string) get_post_meta( $post_id, 'psa_molecular_weight', true );
$psa_aliases = (string) get_post_meta( $post_id, 'psa_aliases', true );
$this->write_meta_from_psa( $post_id, $psa_formula, $psa_weight, $psa_aliases );
return 'copied_psa';
}
// No PSA data — attempt PubChem REST lookup.
$psa_cid = (string) get_post_meta( $post_id, 'psa_pubchem_cid', true );
$post = get_post( $post_id );
$name = $post ? sanitize_text_field( $post->post_title ) : '';
$data = ( '' !== $psa_cid && ctype_digit( $psa_cid ) )
? $this->pubchem->fetch_by_cid( $psa_cid )
: null;
if ( null === $data && '' !== $name ) {
$data = $this->pubchem->fetch_by_name( $name );
}
if ( null === $data ) {
error_log(
sprintf(
'[PR Core Migration 0004] Post ID %d (%s): PubChem returned no data — leaving meta empty.',
$post_id,
esc_html( $name )
)
);
return 'pubchem_skip';
}
$formula = PR_Core_Schema_Sanitizers::sanitize_molecular_formula( $data['formula'] );
$weight = (float) $data['weight'];
$aliases_json = $this->synonyms_to_json( $data['synonyms'] ?? array() );
$this->write_pr_meta( $post_id, $formula, $weight, $aliases_json );
error_log(
sprintf(
'[PR Core Migration 0004] Post ID %d (%s): PubChem-sourced formula=%s weight=%s',
$post_id,
esc_html( $name ),
$formula,
(string) $weight
)
);
return 'pubchem_ok';
}
/**
* Write PR Core meta from PSA source values.
*
* Input is UNTRUSTED — sanitizes before writing.
*
* @param int $post_id WordPress post ID.
* @param string $formula Raw psa_molecular_formula value.
* @param string $weight Raw psa_molecular_weight value (may include " Da").
* @param string $aliases Raw psa_aliases value (comma-separated).
* @return void
*/
private function write_meta_from_psa( int $post_id, string $formula, string $weight, string $aliases ): void {
$clean_formula = PR_Core_Schema_Sanitizers::sanitize_molecular_formula( $formula );
$clean_weight = $this->parse_weight( $weight );
$clean_aliases = $this->parse_aliases_string( $aliases );
$this->write_pr_meta( $post_id, $clean_formula, $clean_weight, $clean_aliases );
}
/**
* Write the three PR Core meta keys for a post.
*
* Only writes a field when it has a usable value to avoid overwriting
* existing data with empty placeholders.
*
* @param int $post_id WordPress post ID.
* @param string $formula Sanitized formula (empty = skip).
* @param float $weight Parsed weight in g/mol (0 = skip).
* @param string $aliases JSON array string (empty array = skip).
* @return void
*/
private function write_pr_meta( int $post_id, string $formula, float $weight, string $aliases ): void {
if ( '' !== $formula ) {
update_post_meta( $post_id, self::META_FORMULA, $formula );
}
if ( $weight > 0.0 ) {
update_post_meta( $post_id, self::META_WEIGHT, (string) $weight );
} elseif ( '' !== $formula ) {
error_log( '[PR Core Migration 0004] Post ID ' . $post_id . ': weight parsed to 0 — check source.' );
}
$decoded = json_decode( $aliases, true );
if ( is_array( $decoded ) && count( $decoded ) > 0 ) {
update_post_meta( $post_id, self::META_ALIASES, $aliases );
}
}
/**
* Parse a molecular weight string to a float.
*
* Strips trailing " Da" suffix (case-insensitive) and whitespace.
* Returns 0.0 on parse failure.
*
* @param string $value Raw weight value (e.g., "1419.5 Da").
* @return float Parsed weight in g/mol, 0.0 on failure.
*/
public function parse_weight( string $value ): float {
$clean = trim( preg_replace( '/\s*[Dd][Aa]\s*$/', '', trim( $value ) ) ?? '' );
return (float) $clean;
}
/**
* Parse a comma-separated alias string into a sanitized JSON array string.
*
* Input is UNTRUSTED — each alias is sanitized individually.
*
* @param string $value Comma-separated aliases.
* @return string JSON array of sanitized strings, or '[]' if empty.
*/
public function parse_aliases_string( string $value ): string {
if ( '' === trim( $value ) ) {
return '[]';
}
$clean = array();
foreach ( explode( ',', $value ) as $part ) {
$alias = sanitize_text_field( trim( $part ) );
if ( '' !== $alias ) {
$clean[] = $alias;
}
}
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
}
/**
* Convert a raw synonyms array from PubChem to a JSON string.
*
* Sanitizes each synonym. Returns '[]' on empty input.
*
* @param string[] $synonyms Raw synonym strings from PubChem.
* @return string JSON array of sanitized strings.
*/
private function synonyms_to_json( array $synonyms ): string {
$clean = array();
foreach ( $synonyms as $syn ) {
$alias = sanitize_text_field( (string) $syn );
if ( '' !== $alias ) {
$clean[] = $alias;
}
}
return wp_json_encode( $clean, JSON_UNESCAPED_UNICODE ) ?: '[]'; // phpcs:ignore Universal.Operators.DisallowShortTernary.Found
}
/**
* Get IDs of all published peptide posts.
*
* @return int[]
*/
private function get_all_peptide_ids(): array {
$posts = get_posts(
array(
'post_type' => PR_Core_Peptide_CPT::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
)
);
return array_map( 'intval', is_array( $posts ) ? $posts : array() );
}
}