Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/8066-import-dedup-by-source-id
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Fix duplicate posts when re-importing content with empty slug columns by persisting the source import id on each imported post.
9 changes: 9 additions & 0 deletions config/psalm/psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4416,6 +4416,10 @@
<PossiblyNullPropertyAssignmentValue>
<code><![CDATA[$task]]></code>
</PossiblyNullPropertyAssignmentValue>
<DocblockTypeContradiction>
<code><![CDATA[! $this->task]]></code>
<code><![CDATA[! $this->task]]></code>
</DocblockTypeContradiction>
<PropertyNotSetInConstructor>
<code><![CDATA[$data]]></code>
<code><![CDATA[$default_author]]></code>
Expand All @@ -4425,14 +4429,19 @@
<code><![CDATA[$schema]]></code>
<code><![CDATA[$task]]></code>
</PropertyNotSetInConstructor>
<RedundantCast>
<code><![CDATA[(string) $existing]]></code>
</RedundantCast>
<RedundantConditionGivenDocblockType>
<code><![CDATA[! empty( $import_id ) && $this->task]]></code>
<code><![CDATA[$this->task]]></code>
<code><![CDATA[null !== $value]]></code>
<code><![CDATA['' !== $import_id && $this->task]]></code>
</RedundantConditionGivenDocblockType>
<UndefinedMethod>
<code><![CDATA[add_attachment_task]]></code>
<code><![CDATA[add_line_warning]]></code>
<code><![CDATA[get_import_id]]></code>
<code><![CDATA[set_import_id]]></code>
</UndefinedMethod>
<UnsafeInstantiation>
Expand Down
29 changes: 26 additions & 3 deletions includes/data-port/models/class-sensei-data-port-schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ abstract class Sensei_Data_Port_Schema {
const COLUMN_ID = 'id';
const COLUMN_SLUG = 'slug';

/**
* Post meta key used to record the source import id on every imported post.
*
* Provides durable per-post identity so re-imports of the same source row
* resolve to the existing post even when the slug is empty.
*
* @since $$next-version$$
*
* @var string
*/
const META_KEY_IMPORT_ID = '_sensei_import_id';

/**
* Post statuses supported by the data port. Export coerces any other status to
* `draft`, and import only accepts these; keeping both sides in sync avoids a
Expand Down Expand Up @@ -73,6 +85,17 @@ public function get_column_slug() {
return self::COLUMN_SLUG;
}

/**
* Get the post meta key used to record the source import id.
*
* @since $$next-version$$
*
* @return string
*/
public function get_meta_key_import_id() {
return self::META_KEY_IMPORT_ID;
}

/**
* Get the optional fields in the schema.
*
Expand All @@ -84,7 +107,7 @@ public function get_required_fields() {
return array_values(
array_filter(
array_map(
function( $field ) use ( $schema ) {
function ( $field ) use ( $schema ) {
if ( ! empty( $schema[ $field ]['required'] ) ) {
return $field;
}
Expand All @@ -108,7 +131,7 @@ public function get_optional_fields() {
return array_values(
array_filter(
array_map(
function( $field ) use ( $schema ) {
function ( $field ) use ( $schema ) {
if ( empty( $schema[ $field ]['required'] ) ) {
return $field;
}
Expand All @@ -135,7 +158,7 @@ protected function get_allowed_mime_types( $filter_type = null ) {

return array_filter(
get_allowed_mime_types(),
function( $mime_type ) use ( $filter_type ) {
function ( $mime_type ) use ( $filter_type ) {
return 0 === strpos( $mime_type, $filter_type );
}
);
Expand Down
111 changes: 99 additions & 12 deletions includes/data-port/models/class-sensei-import-model.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,28 +106,93 @@ public static function from_source_array( $line_number, $data, Sensei_Data_Port_
/**
* Check to see if the post already exists in the database.
*
* @return int
* Lookup precedence:
* 1. Slug (`post_name`).
* 2. Job-local map (`Sensei_Import_Job::get_import_id()`).
* 3. Source-import-id post meta (`_sensei_import_id`) — durable across runs.
*
* Rows with no slug and no source `id` cannot be matched durably and will
* be re-inserted on re-import.
*
* @since $$next-version$$
*
* @return int|null
*/
protected function get_existing_post_id() {
$post_id = null;
$data = $this->get_data();
$data = $this->get_data();

if ( ! empty( $data[ $this->schema->get_column_slug() ] ) ) {
// 1. Slug fast path — unchanged behavior.
$slug = isset( $data[ $this->schema->get_column_slug() ] ) ? (string) $data[ $this->schema->get_column_slug() ] : '';
if ( '' !== $slug ) {
$existing_posts = get_posts(
[
array(
'post_type' => $this->schema->get_post_type(),
'post_name__in' => [ $data[ $this->schema->get_column_slug() ] ],
'post_name__in' => array( $slug ),
'posts_per_page' => 1,
'post_status' => 'any',
]
'fields' => 'ids',
)
);

if ( ! empty( $existing_posts[0] ) ) {
return $existing_posts[0]->ID;
return (int) $existing_posts[0];
}
}

return $post_id;
// 2 & 3. Resolve by source import id — first via the in-memory job map,
// then via the durable post meta.
$import_id = isset( $data[ $this->schema->get_column_id() ] ) ? (string) $data[ $this->schema->get_column_id() ] : '';
if ( '' !== $import_id && $this->task ) {
$mapped = $this->task->get_job()->get_import_id( $this->schema->get_post_type(), $import_id );
if ( $mapped ) {
return (int) $mapped;
}

$post_id = $this->get_existing_post_id_by_import_id( $import_id );
if ( $post_id ) {
return $post_id;
}
}

return null;
}

/**
* Look up an existing post id by the durable source import id meta.
*
* Scoped to the schema's post type so `_sensei_import_id = 7` on a course
* does not match a lesson with the same id. Trashed and auto-draft posts
* are excluded to match the slug path, where `post_status => 'any'` never
* returns those statuses.
*
* @since $$next-version$$
*
* @param string $import_id The source import id from the CSV row.
*
* @return int|null
*/
private function get_existing_post_id_by_import_id( $import_id ) {
global $wpdb;

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single indexed postmeta lookup by meta key; no cache layer exists for this path.
$post_id = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT pm.post_id
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = %s
AND pm.meta_value = %s
AND p.post_type = %s
AND p.post_status NOT IN ( 'trash', 'auto-draft' )
ORDER BY p.ID ASC
LIMIT 1",
$this->schema->get_meta_key_import_id(),
$import_id,
$this->schema->get_post_type()
)
);

return $post_id > 0 ? $post_id : null;
}

/**
Expand Down Expand Up @@ -483,13 +548,35 @@ public function add_warnings_to_job() {
}

/**
* Stores an import id to the job.
* Stores an import id on the post and the job.
*
* The post meta is the durable identity; the in-memory job map remains the
* fast path within a single run. An existing meta value is never
* overwritten, so a slug-resolved post whose source id has changed keeps
* the id it was first written with. The in-memory map is also not
* overwritten when the post already carries a different id, matching the
* meta-write contract.
*
* @since $$next-version$$
*/
protected function store_import_id() {
$import_id = $this->get_value( $this->schema->get_column_id() );

if ( ! empty( $import_id ) && $this->task ) {
$this->task->get_job()->set_import_id( $this->schema->get_post_type(), $import_id, $this->get_post_id() );
if ( empty( $import_id ) || ! $this->task ) {
return;
}

$post_id = $this->get_post_id();
$meta_key = $this->schema->get_meta_key_import_id();

$existing = (string) get_post_meta( $post_id, $meta_key, true );

if ( '' === $existing ) {
update_post_meta( $post_id, $meta_key, (string) $import_id );
}

if ( '' === $existing || (string) $existing === (string) $import_id ) {
$this->task->get_job()->set_import_id( $this->schema->get_post_type(), $import_id, $post_id );
}
}

Expand Down
Loading
Loading