@@ -201,7 +201,7 @@ private function streamAndMerge(string $table, array $key, array $hashCols): arr
201201 $ hashExpr = $ this ->buildHashExpression ($ hashCols );
202202 $ pkCols = $ this ->quoteCols ($ key );
203203 $ pkSelect = implode (', ' , $ pkCols );
204- $ orderBy = implode ( ' , ' , $ pkCols );
204+ [ $ orderBy, $ numeric ] = $ this -> keyOrder ( $ table , $ key );
205205 $ q = $ this ->tableName ($ table );
206206
207207 $ srcPdo = $ this ->source ->getPdo ();
@@ -239,7 +239,7 @@ private function streamAndMerge(string $table, array $key, array $hashCols): arr
239239 $ insertPKs [] = Arr::only ($ srcRow , $ key );
240240 $ srcRow = $ src ->fetch ();
241241 } else {
242- $ cmp = $ this ->comparePK ($ srcRow , $ tgtRow , $ key );
242+ $ cmp = $ this ->comparePK ($ srcRow , $ tgtRow , $ key, $ numeric );
243243 if ($ cmp < 0 ) {
244244 $ insertPKs [] = Arr::only ($ srcRow , $ key );
245245 $ srcRow = $ src ->fetch ();
@@ -333,25 +333,115 @@ public function buildHashExpression(array $columns): string
333333
334334 /**
335335 * Compare two PK tuples for merge-sort ordering.
336- * Returns < 0 if $a < $b, 0 if equal, > 0 if $a > $b.
336+ *
337+ * Must order keys exactly as the hash query's ORDER BY does, or the merge
338+ * falls out of step and reports rows both sides have as an insert and a
339+ * delete. `$numeric` names the key columns compared as numbers (see
340+ * keyOrder()); every other column compares byte by byte. Without it, a
341+ * value that looks numeric is compared as a number — fine for an integer
342+ * key, wrong for a text key holding '9' and '10'.
343+ *
344+ * @param string[]|null $numeric
337345 */
338- public function comparePK (array $ a , array $ b , array $ key ): int
346+ public function comparePK (array $ a , array $ b , array $ key, ? array $ numeric = null ): int
339347 {
340348 foreach ($ key as $ col ) {
341349 $ av = $ a [$ col ] ?? '' ;
342350 $ bv = $ b [$ col ] ?? '' ;
343- if (is_numeric ($ av ) && is_numeric ($ bv )) {
344- $ cmp = $ av <=> $ bv ;
345- } else {
346- $ cmp = strcmp ((string ) $ av , (string ) $ bv );
347- }
351+ $ asNumber = $ numeric === null ? is_numeric ($ av ) && is_numeric ($ bv ) : in_array ($ col , $ numeric , true );
352+ $ cmp = $ asNumber ? $ av <=> $ bv : strcmp ((string ) $ av , (string ) $ bv );
348353 if ($ cmp !== 0 ) {
349354 return $ cmp ;
350355 }
351356 }
352357 return 0 ;
353358 }
354359
360+ /**
361+ * The hash query's ORDER BY, and which key columns comparePK() treats as
362+ * numbers — chosen together so the database and the merge agree.
363+ *
364+ * A numeric key sorts natively and compares as a number. Anything else
365+ * sorts by the bytes of its text, which is what strcmp() compares: under
366+ * a linguistic collation (en_US, utf8mb4_0900_ai_ci) the database puts
367+ * 'a' before 'B' where strcmp() puts 'B' first. A uuid keeps its native
368+ * order, which is already its hex text's, so its index still serves.
369+ *
370+ * @return array{0: string, 1: string[]}
371+ */
372+ private function keyOrder (string $ table , array $ key ): array
373+ {
374+ $ kinds = $ this ->keyKinds ($ table , $ key );
375+ $ numeric = [];
376+ $ terms = [];
377+ foreach ($ key as $ col ) {
378+ $ quoted = $ this ->quoteIdentifier ($ col );
379+ $ kind = $ kinds [$ col ] ?? 'text ' ;
380+ if ($ kind === 'number ' ) {
381+ $ numeric [] = $ col ;
382+ $ terms [] = $ quoted ;
383+ } elseif ($ kind === 'uuid ' ) {
384+ $ terms [] = $ quoted ;
385+ } else {
386+ $ terms [] = match ($ this ->driver ) {
387+ 'pgsql ' => "( {$ quoted })::text COLLATE \"C \"" ,
388+ 'mysql ' => "CAST( {$ quoted } AS BINARY) " ,
389+ default => "CAST( {$ quoted } AS TEXT) COLLATE BINARY " ,
390+ };
391+ }
392+ }
393+ return [implode (', ' , $ terms ), $ numeric ];
394+ }
395+
396+ /**
397+ * 'number', 'uuid' or 'text' for each key column, from the source's
398+ * catalog. A column it cannot classify is 'text', the order that is
399+ * always consistent with strcmp().
400+ *
401+ * @return array<string, string>
402+ */
403+ private function keyKinds (string $ table , array $ key ): array
404+ {
405+ $ kinds = [];
406+ try {
407+ if ($ this ->driver === 'pgsql ' ) {
408+ $ rows = $ this ->source ->select (
409+ "SELECT a.attname AS name, t.typcategory AS category, t.typname AS type
410+ FROM pg_attribute a
411+ JOIN pg_type d ON d.oid = a.atttypid
412+ -- A domain sorts as its base type.
413+ JOIN pg_type t ON t.oid = CASE WHEN d.typtype = 'd' THEN d.typbasetype ELSE d.oid END
414+ WHERE a.attrelid = to_regclass(?) AND a.attnum > 0 AND NOT a.attisdropped " ,
415+ [$ this ->tableName ($ table )]
416+ );
417+ foreach ($ rows as $ row ) {
418+ $ row = (array ) $ row ;
419+ $ kinds [$ row ['name ' ]] = $ row ['category ' ] === 'N ' ? 'number ' : ($ row ['type ' ] === 'uuid ' ? 'uuid ' : 'text ' );
420+ }
421+ } elseif ($ this ->driver === 'mysql ' ) {
422+ $ rows = $ this ->source ->select (
423+ 'SELECT COLUMN_NAME AS name, DATA_TYPE AS type FROM information_schema.COLUMNS
424+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? ' ,
425+ [$ table ]
426+ );
427+ $ numberTypes = ['tinyint ' , 'smallint ' , 'mediumint ' , 'int ' , 'integer ' , 'bigint ' , 'decimal ' , 'numeric ' , 'float ' , 'double ' , 'real ' , 'year ' ];
428+ foreach ($ rows as $ row ) {
429+ $ row = array_change_key_case ((array ) $ row , CASE_LOWER );
430+ $ kinds [$ row ['name ' ]] = in_array (strtolower ($ row ['type ' ]), $ numberTypes , true ) ? 'number ' : 'text ' ;
431+ }
432+ } else {
433+ foreach ($ this ->source ->select ('PRAGMA table_info( ' . $ this ->quoteIdentifier ($ table ) . ') ' ) as $ row ) {
434+ $ row = (array ) $ row ;
435+ // SQLite's own affinity rules: a declared type naming INT, REAL, FLOA, DOUB, NUM or DEC.
436+ $ kinds [$ row ['name ' ]] = preg_match ('/INT|REAL|FLOA|DOUB|NUM|DEC/i ' , (string ) $ row ['type ' ]) ? 'number ' : 'text ' ;
437+ }
438+ }
439+ } catch (\Throwable $ e ) {
440+ Logger::info ("Could not read key column types for ` $ table`; ordering keys as text: " . $ e ->getMessage ());
441+ }
442+ return array_intersect_key ($ kinds , array_flip ($ key ));
443+ }
444+
355445 /**
356446 * Fetch full rows for a batch of PKs, ordered by PK for deterministic output.
357447 *
0 commit comments