From a50c75b8b90b48ee0a30ccc29b33d2033408f6fe Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Fri, 7 Aug 2026 05:53:09 +0600 Subject: [PATCH 1/2] Fix duplicate posts when re-importing content without slugs Imported posts now store the source import id as private post meta. When a slug is empty, the import looks up that meta to find the existing post instead of creating a new one. This stops re-importing the same CSV from duplicating draft rows that have no slug. --- changelog/8066-import-dedup-by-source-id | 4 + .../models/class-sensei-data-port-schema.php | 29 +++- .../models/class-sensei-import-model.php | 111 ++++++++++-- ...st-class-sensei-data-port-lesson-model.php | 158 ++++++++++++++++++ 4 files changed, 287 insertions(+), 15 deletions(-) create mode 100644 changelog/8066-import-dedup-by-source-id diff --git a/changelog/8066-import-dedup-by-source-id b/changelog/8066-import-dedup-by-source-id new file mode 100644 index 0000000000..ea49c1b2a1 --- /dev/null +++ b/changelog/8066-import-dedup-by-source-id @@ -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. \ No newline at end of file diff --git a/includes/data-port/models/class-sensei-data-port-schema.php b/includes/data-port/models/class-sensei-data-port-schema.php index eb8348a65b..27b1bc7151 100644 --- a/includes/data-port/models/class-sensei-data-port-schema.php +++ b/includes/data-port/models/class-sensei-data-port-schema.php @@ -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 @@ -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. * @@ -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; } @@ -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; } @@ -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 ); } ); diff --git a/includes/data-port/models/class-sensei-import-model.php b/includes/data-port/models/class-sensei-import-model.php index 9baa471011..fc942f5946 100644 --- a/includes/data-port/models/class-sensei-import-model.php +++ b/includes/data-port/models/class-sensei-import-model.php @@ -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; } /** @@ -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 ); } } diff --git a/tests/unit-tests/data-port/models/test-class-sensei-data-port-lesson-model.php b/tests/unit-tests/data-port/models/test-class-sensei-data-port-lesson-model.php index 78a52a1f0f..2b22befd7c 100644 --- a/tests/unit-tests/data-port/models/test-class-sensei-data-port-lesson-model.php +++ b/tests/unit-tests/data-port/models/test-class-sensei-data-port-lesson-model.php @@ -738,4 +738,162 @@ public function testSyncPost_PrivateStatusGiven_CreatesLessonWithPrivateStatus() $this->assertNotEmpty( $post, 'Private lesson should exist.' ); $this->assertEquals( 'private', $post[0]->post_status, 'Lesson post status should be private.' ); } + + /** + * A slug match resolves the existing post even when the source id is absent. + */ + public function testGetExistingPostId_BySlug_ReturnsExisting() { + $existing_id = $this->factory->lesson->create( array( 'post_name' => 'intro' ) ); + + $task = new Sensei_Import_Lessons( Sensei_Import_Job::create( 'test', 0 ) ); + $data = array( + Sensei_Data_Port_Lesson_Schema::COLUMN_TITLE => 'Intro lesson', + Sensei_Data_Port_Lesson_Schema::COLUMN_SLUG => 'intro', + ); + $model = Sensei_Import_Lesson_Model::from_source_array( 1, $data, new Sensei_Data_Port_Lesson_Schema(), $task ); + + $this->assertSame( $existing_id, $model->get_post_id(), 'Slug match should resolve to the existing post id.' ); + } + + /** + * The slug-matched post is treated as not-new, so sync_post updates it + * rather than inserting a duplicate. + */ + public function testGetExistingPostId_BySlug_MarksPostAsNotNew() { + $this->factory->lesson->create( array( 'post_name' => 'intro' ) ); + + $task = new Sensei_Import_Lessons( Sensei_Import_Job::create( 'test', 0 ) ); + $data = array( + Sensei_Data_Port_Lesson_Schema::COLUMN_TITLE => 'Intro lesson', + Sensei_Data_Port_Lesson_Schema::COLUMN_SLUG => 'intro', + ); + $model = Sensei_Import_Lesson_Model::from_source_array( 1, $data, new Sensei_Data_Port_Lesson_Schema(), $task ); + + $this->assertFalse( $model->is_new(), 'Slug match should be treated as not-new.' ); + } + + /** + * When the slug is empty but the source id matches a stored meta, the post + * is resolved via the durable identity — the +22 case from #8066. + */ + public function testGetExistingPostId_ByIdWithNoSlug_ReturnsExisting() { + $existing_id = $this->factory->lesson->create( array( 'post_name' => '' ) ); + update_post_meta( $existing_id, Sensei_Data_Port_Schema::META_KEY_IMPORT_ID, '42' ); + + $task = new Sensei_Import_Lessons( Sensei_Import_Job::create( 'test', 0 ) ); + $data = array( + Sensei_Data_Port_Lesson_Schema::COLUMN_ID => '42', + Sensei_Data_Port_Lesson_Schema::COLUMN_TITLE => 'No slug lesson', + ); + $model = Sensei_Import_Lesson_Model::from_source_array( 1, $data, new Sensei_Data_Port_Lesson_Schema(), $task ); + + $this->assertSame( $existing_id, $model->get_post_id(), 'Meta id match should resolve to the existing post id.' ); + } + + /** + * The id meta written by an earlier run resolves a new run even when the + * in-memory job map is empty. + */ + public function testGetExistingPostId_IdMatchesPostInAnotherJob_ReturnsThatPost() { + $first_task = new Sensei_Import_Lessons( Sensei_Import_Job::create( 'first', 0 ) ); + $first_data = array( + Sensei_Data_Port_Lesson_Schema::COLUMN_ID => '99', + Sensei_Data_Port_Lesson_Schema::COLUMN_TITLE => 'Carried over', + ); + $first_model = Sensei_Import_Lesson_Model::from_source_array( 1, $first_data, new Sensei_Data_Port_Lesson_Schema(), $first_task ); + + $first_model->sync_post(); + + $second_task = new Sensei_Import_Lessons( Sensei_Import_Job::create( 'second', 0 ) ); + $second_model = Sensei_Import_Lesson_Model::from_source_array( 1, $first_data, new Sensei_Data_Port_Lesson_Schema(), $second_task ); + + $existing = get_posts( + array( + 'post_type' => 'lesson', + 'post_status' => 'any', + 's' => 'Carried over', + 'posts_per_page' => -1, + 'fields' => 'ids', + ) + ); + + $this->assertCount( 1, $existing, 'Exactly one lesson should exist after re-import (no duplicate insert).' ); + $this->assertSame( $first_model->get_post_id(), $second_model->get_post_id(), 'Meta written by an earlier run should resolve the post in a new run.' ); + } + + /** + * The id lookup is scoped to the schema's post type so a matching id on a + * different post type does not collide. + */ + public function testGetExistingPostId_IdScopedToPostType_DoesNotMatchOtherType() { + $course_id = $this->factory->course->create(); + update_post_meta( $course_id, Sensei_Data_Port_Schema::META_KEY_IMPORT_ID, '7' ); + + $task = new Sensei_Import_Lessons( Sensei_Import_Job::create( 'test', 0 ) ); + $data = array( + Sensei_Data_Port_Lesson_Schema::COLUMN_ID => '7', + Sensei_Data_Port_Lesson_Schema::COLUMN_TITLE => 'Lesson with course id', + ); + $model = Sensei_Import_Lesson_Model::from_source_array( 1, $data, new Sensei_Data_Port_Lesson_Schema(), $task ); + + $this->assertTrue( $model->is_new(), 'Lesson with an id only present on a course should be treated as new.' ); + } + + /** + * A row with no source id and no slug has no durable identity and is + * re-inserted on re-import. Documented limitation. + */ + public function testGetExistingPostId_NoIdAndNoSlug_CreatesNew() { + $this->factory->lesson->create( + array( + 'post_title' => 'Same title', + 'post_name' => '', + ) + ); + + $task = new Sensei_Import_Lessons( Sensei_Import_Job::create( 'test', 0 ) ); + $data = array( + Sensei_Data_Port_Lesson_Schema::COLUMN_TITLE => 'Same title', + ); + $model = Sensei_Import_Lesson_Model::from_source_array( 1, $data, new Sensei_Data_Port_Lesson_Schema(), $task ); + + $this->assertTrue( $model->is_new(), 'No id and no slug resolves to no existing post; model is new.' ); + $this->assertNull( $model->get_post_id(), 'No id and no slug resolves to no post id before sync_post.' ); + } + + /** + * After a successful import, the source id is written to the post meta. + */ + public function testStoreImportId_WritesMetaAfterInsert() { + $task = new Sensei_Import_Lessons( Sensei_Import_Job::create( 'test', 0 ) ); + $data = array( + Sensei_Data_Port_Lesson_Schema::COLUMN_ID => '500', + Sensei_Data_Port_Lesson_Schema::COLUMN_TITLE => 'Meta write lesson', + ); + $model = Sensei_Import_Lesson_Model::from_source_array( 1, $data, new Sensei_Data_Port_Lesson_Schema(), $task ); + + $model->sync_post(); + + $this->assertSame( '500', (string) get_post_meta( $model->get_post_id(), Sensei_Data_Port_Schema::META_KEY_IMPORT_ID, true ) ); + } + + /** + * An existing meta value is not overwritten by a later import. + */ + public function testStoreImportId_DoesNotOverwriteDifferentId() { + $existing_id = $this->factory->lesson->create( array( 'post_name' => 'kept' ) ); + update_post_meta( $existing_id, Sensei_Data_Port_Schema::META_KEY_IMPORT_ID, 'a' ); + + $task = new Sensei_Import_Lessons( Sensei_Import_Job::create( 'test', 0 ) ); + $data = array( + Sensei_Data_Port_Lesson_Schema::COLUMN_ID => 'b', + Sensei_Data_Port_Lesson_Schema::COLUMN_TITLE => 'Updated', + Sensei_Data_Port_Lesson_Schema::COLUMN_SLUG => 'kept', + ); + $model = Sensei_Import_Lesson_Model::from_source_array( 1, $data, new Sensei_Data_Port_Lesson_Schema(), $task ); + + $model->sync_post(); + + $this->assertSame( 'a', (string) get_post_meta( $existing_id, Sensei_Data_Port_Schema::META_KEY_IMPORT_ID, true ) ); + } } From 192ac438b4ccbbfa8e4b8456e909b43eaed12478 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Sun, 16 Aug 2026 21:47:49 +0600 Subject: [PATCH 2/2] ci(psalm): baseline import-model re-scan errors --- config/psalm/psalm-baseline.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/config/psalm/psalm-baseline.xml b/config/psalm/psalm-baseline.xml index 91f6961fb5..0d1b919fb9 100644 --- a/config/psalm/psalm-baseline.xml +++ b/config/psalm/psalm-baseline.xml @@ -4416,6 +4416,10 @@ + + task]]> + task]]> + @@ -4425,14 +4429,19 @@ + + + task]]> task]]> + task]]> +