@prefix sioc: <http://rdfs.org/sioc/ns#> .
@prefix dc: <http://purl.org/dc/elements/1.1/> .
@prefix dcterms: <http://purl.org/dc/terms/> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix content: <http://purl.org/rss/1.0/modules/content/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

<https://lhero.org/?post_type=lh-portfolio&#038;p=147167>
  a sioc:Post ;
  dc:title "Database Schema" ;
  dcterms:identifier 147167 ;
  dc:modified "2026-09-11T16:41:48Z"^^xsd:dateTime ;
  dc:created "2026-09-09T01:46:07Z"^^xsd:dateTime ;
  sioc:link <https://lhero.org/portfolio/lh-profile-page/database-schema/> ;
  sioc:has_creator <https://lhero.org/author/1/#account> ;
  sioc:has_container <https://lhero.org/#posts> ;
  content:encoded "<ul class=\"lh_portfolio-meta\"><li><strong>Type:</strong> Doc-section</li><li><strong>Part of:</strong> <a href=\"https://lhero.org/portfolio/lh-profile-page/\">LH Profile Page</a></li></ul><h1 class=\"wp-block-heading\">Database Schema (User Meta Fields)</h1><p class=\"wp-block-paragraph\">All fields are stored as <strong>user meta</strong> (<code>wp_usermeta</code>), under the plugin&#039;s namespace prefix <code>lh_profile-</code>. There is no dedicated database table.</p><h2 class=\"wp-block-heading\">Registered meta (validated via <code>register_meta()</code>)</h2><p class=\"wp-block-paragraph\">These fields have a <code>sanitize_callback</code> registered, so <strong>any</strong> direct <code>update_user_meta()</code> write &#8211; from this plugin, the REST API, or any other plugin &#8211; is validated/normalised, not just writes that go through this plugin&#039;s own save paths.</p><figure class=\"wp-block-table blocks-engine-table-57ee1854d269-3\"><table><thead><tr><th>Meta key</th><th>Type</th><th>Format</th><th>Notes</th></tr></thead><tbody><tr><td><code>lh_profile-phone</code></td><td>string</td><td>E.164 (<code>+61...</code>)</td><td>Validated via <code>giggsey/libphonenumber-for-php-lite</code> (Composer). A full backfill (this session) converted ~8,300 legacy-format rows to E.164.</td></tr><tr><td><code>lh_profile-secondary_phone</code></td><td>string</td><td>E.164</td><td>Same validator as phone.</td></tr><tr><td><code>lh_profile-gender</code></td><td>string</td><td><code>Male</code>|<code>Female</code>|<code>Organisation</code>|<code>Robot</code></td><td>Fixed allowed-value set.</td></tr><tr><td><code>lh_profile-birthdate</code></td><td>string</td><td><code>Y-m-d</code></td><td>Changed from a UNIX timestamp to a plain date string in v2.13 &#8211; see Architectural Notes for why, and the full migration history.</td></tr><tr><td><code>lh_profile-street_address</code></td><td>string</td><td>free text</td><td><code>sanitize_text_field()</code> only &#8211; no richer format exists.</td></tr><tr><td><code>lh_profile-town</code></td><td>string</td><td>free text</td><td><code>sanitize_text_field()</code> only.</td></tr><tr><td><code>lh_profile-postcode</code></td><td>string</td><td>free text</td><td><code>sanitize_text_field()</code> only for now &#8211; real per-country format validation (AU 4-digit, US ZIP, UK alphanumeric) is a known future candidate, not yet built.</td></tr><tr><td><code>lh_profile-state_province</code></td><td>string</td><td>free text</td><td><code>sanitize_text_field()</code> only.</td></tr><tr><td><code>lh_profile-country</code></td><td>string</td><td>free text</td><td><code>sanitize_text_field()</code> only &#8211; a plain text field, not a country-code dropdown.</td></tr></tbody></table></figure><h2 class=\"wp-block-heading\">Unregistered meta</h2><figure class=\"wp-block-table blocks-engine-table-57ee1854d269-4\"><table><thead><tr><th>Meta key</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td><code>lh_profile-birthdate_readable</code></td><td>string</td><td>Companion field alongside <code>lh_profile-birthdate</code>, written by <code>handle_birthdate_update()</code>. Historically held the pre-parse date string; since the v2.13 format change both fields hold the same <code>Y-m-d</code> value, making this field largely redundant going forward, but it&#039;s kept for backward compatibility since other code (<code>filter_query_joins()</code> for the &quot;Users Insights&quot; plugin integration) reads it directly.</td></tr><tr><td><code>lh_profile-emails</code></td><td>array</td><td>Additional/secondary emails, serialized array of validated email strings. <strong>Slated for removal</strong> &#8211; superseded by <code>lh-user-identity</code>. Not extracted into its own field class for this reason. As of this session, 1,888 users have a non-empty value here, largely following a <code>firstname.lastname&#64;theaustraliatimes.com.au</code> &lt;-&gt; real-email cross-reference pattern that turned out to indicate a large number of duplicate user accounts &#8211; see the exploration brief referenced in Architectural Notes.</td></tr><tr><td><code>lh_profile-updated_time</code></td><td>int</td><td>UNIX timestamp of the last time any &quot;non-native&quot; field (see below) was updated. Set by <code>track_last_meta_update_time()</code>.</td></tr><tr><td><code>lh_profile-updated_field</code></td><td>string</td><td>Which meta key was last updated, alongside <code>updated_time</code>.</td></tr></tbody></table></figure><h2 class=\"wp-block-heading\">Core WordPress fields (not this plugin&#039;s own meta)</h2><figure class=\"wp-block-table blocks-engine-table-57ee1854d269-5\"><table><thead><tr><th>Field</th><th>Storage</th><th>Notes</th></tr></thead><tbody><tr><td>First name</td><td><code>first_name</code> usermeta (WP core key)</td><td>No <code>register_meta()</code> here deliberately &#8211; registering a sanitize_callback against a core-recognised key would affect any other code touching that exact key, not just this plugin.</td></tr><tr><td>Last name</td><td><code>last_name</code> usermeta (WP core key)</td><td>Same reasoning as first name.</td></tr><tr><td>Display name</td><td><code>wp_users.display_name</code> column</td><td>Not usermeta at all &#8211; <code>register_meta()</code> doesn&#039;t apply.</td></tr></tbody></table></figure><h2 class=\"wp-block-heading\">&quot;Non-native fields&quot; list</h2><p class=\"wp-block-paragraph\"><code>return_all_non_native_fields()</code> returns: <code>street_address</code>, <code>town</code>, <code>postcode</code>, <code>gender</code>, <code>phone</code>, <code>birthdate</code> (all namespace-prefixed). This list drives several cross-cutting behaviours: vcard export tracking, notification token generation, the &quot;updated_time&quot;/&quot;updated_field&quot; tracking, and the <code>prevent_bad_meta_values</code> filter (rejects empty-string writes to any of these keys).</p>"^^rdf:XMLLiteral ;
  sioc:content "Type: Doc-sectionPart of: LH Profile PageDatabase Schema (User Meta Fields)All fields are stored as user meta (wp_usermeta), under the plugin&#039;s namespace prefix lh_profile-. There is no dedicated database table.Registered meta (validated via register_meta())These fields have a sanitize_callback registered, so any direct update_user_meta() write &#8211; from this plugin, the REST API, or any other plugin &#8211; is validated/normalised, not just writes that go through this plugin&#039;s own save paths.Meta keyTypeFormatNoteslh_profile-phonestringE.164 (+61...)Validated via giggsey/libphonenumber-for-php-lite (Composer). A full backfill (this session) converted ~8,300 legacy-format rows to E.164.lh_profile-secondary_phonestringE.164Same validator as phone.lh_profile-genderstringMale|Female|Organisation|RobotFixed allowed-value set.lh_profile-birthdatestringY-m-dChanged from a UNIX timestamp to a plain date string in v2.13 &#8211; see Architectural Notes for why, and the full migration history.lh_profile-street_addressstringfree textsanitize_text_field() only &#8211; no richer format exists.lh_profile-townstringfree textsanitize_text_field() only.lh_profile-postcodestringfree textsanitize_text_field() only for now &#8211; real per-country format validation (AU 4-digit, US ZIP, UK alphanumeric) is a known future candidate, not yet built.lh_profile-state_provincestringfree textsanitize_text_field() only.lh_profile-countrystringfree textsanitize_text_field() only &#8211; a plain text field, not a country-code dropdown.Unregistered metaMeta keyTypeNoteslh_profile-birthdate_readablestringCompanion field alongside lh_profile-birthdate, written by handle_birthdate_update(). Historically held the pre-parse date string; since the v2.13 format change both fields hold the same Y-m-d value, making this field largely redundant going forward, but it&#039;s kept for backward compatibility since other code (filter_query_joins() for the &quot;Users Insights&quot; plugin integration) reads it directly.lh_profile-emailsarrayAdditional/secondary emails, serialized array of validated email strings. Slated for removal &#8211; superseded by lh-user-identity. Not extracted into its own field class for this reason. As of this session, 1,888 users have a non-empty value here, largely following a firstname.lastname&#64;theaustraliatimes.com.au &lt;-&gt; real-email cross-reference pattern that turned out to indicate a large number of duplicate user accounts &#8211; see the exploration brief referenced in Architectural Notes.lh_profile-updated_timeintUNIX timestamp of the last time any &quot;non-native&quot; field (see below) was updated. Set by track_last_meta_update_time().lh_profile-updated_fieldstringWhich meta key was last updated, alongside updated_time.Core WordPress fields (not this plugin&#039;s own meta)FieldStorageNotesFirst namefirst_name usermeta (WP core key)No register_meta() here deliberately &#8211; registering a sanitize_callback against a core-recognised key would affect any other code touching that exact key, not just this plugin.Last namelast_name usermeta (WP core key)Same reasoning as first name.Display namewp_users.display_name columnNot usermeta at all &#8211; register_meta() doesn&#039;t apply.&quot;Non-native fields&quot; listreturn_all_non_native_fields() returns: street_address, town, postcode, gender, phone, birthdate (all namespace-prefixed). This list drives several cross-cutting behaviours: vcard export tracking, notification token generation, the &quot;updated_time&quot;/&quot;updated_field&quot; tracking, and the prevent_bad_meta_values filter (rejects empty-string writes to any of these keys)." ;
  sioc:topic <https://lhero.org/lh_portfolio-type/doc-section/>, <https://lhero.org/?taxonomy=author&term=cap-1> .

<https://lhero.org/author/1/#account> rdfs:seeAlso <https://lhero.org/author/1/?feed=lhrdf&format=turtle> .
<https://lhero.org/lh_portfolio-type/doc-section/> rdfs:seeAlso <https://lhero.org/lh_portfolio-type/doc-section/?feed=lhrdf&format=turtle> .
<https://lhero.org/?taxonomy=author&term=cap-1> rdfs:seeAlso <https://lhero.org/?taxonomy=author&term=cap-1&feed=lhrdf&format=turtle> .
