Skip to content

Commit fabad98

Browse files
committed
More prod data fixes
1 parent 45926e0 commit fabad98

File tree

1 file changed

+272
-0
lines changed

1 file changed

+272
-0
lines changed

data-migration/src/migrators/_baseMigrator.js

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,17 @@ class BaseMigrator {
509509
return { skip: true };
510510
}
511511

512+
if (this.isNumericStringTypeError(error)) {
513+
const fieldTypeMap = this.extractNumericFieldTypesFromError(error);
514+
const sanitized = this.convertNumericStringFields(upsertData, fieldTypeMap);
515+
516+
if (sanitized.modified) {
517+
const fieldSummary = sanitized.convertedFields.join(', ') || 'numeric fields';
518+
this.manager.logger.warn(`${this.modelName} ${recordLabel}: converted string inputs for ${fieldSummary}; retrying upsert.`);
519+
return { retryData: sanitized.data };
520+
}
521+
}
522+
512523
return null;
513524
}
514525

@@ -522,6 +533,267 @@ class BaseMigrator {
522533
return message.includes('Unable to fit integer value');
523534
}
524535

536+
/**
537+
* Determine if the Prisma error was triggered by passing string values to numeric fields
538+
* @param {Error} error
539+
* @returns {boolean}
540+
*/
541+
isNumericStringTypeError(error) {
542+
if (!error) {
543+
return false;
544+
}
545+
546+
const message = error.message || '';
547+
const numericTypeMismatchPattern = /Expected\s+(?:Float|Int|Decimal|BigInt|Double|Real|Numeric)[^,]*,\s*provided\s+String/i;
548+
549+
if (numericTypeMismatchPattern.test(message)) {
550+
return true;
551+
}
552+
553+
if (Prisma?.PrismaClientValidationError && error instanceof Prisma.PrismaClientValidationError) {
554+
return numericTypeMismatchPattern.test(message);
555+
}
556+
557+
return false;
558+
}
559+
560+
/**
561+
* Extract field names and expected numeric types from a Prisma validation error message
562+
* @param {Error} error
563+
* @returns {Map<string, string>}
564+
*/
565+
extractNumericFieldTypesFromError(error) {
566+
const message = error?.message || '';
567+
const fieldTypeMap = new Map();
568+
const argumentPattern = /Argument\s+(?:["'`])?([A-Za-z0-9_.\[\]]+)(?:["'`])?\s*:\s*Invalid value provided\.?\s*Expected\s+([A-Za-z0-9]+)[^,]*,\s*provided\s+String/gi;
569+
let match;
570+
571+
while ((match = argumentPattern.exec(message)) !== null) {
572+
const rawPath = match[1];
573+
const expectedType = (match[2] || '').toUpperCase();
574+
const field = this.normalizeErrorFieldPath(rawPath);
575+
576+
if (field && !fieldTypeMap.has(field)) {
577+
fieldTypeMap.set(field, expectedType);
578+
}
579+
}
580+
581+
return fieldTypeMap;
582+
}
583+
584+
/**
585+
* Normalize a Prisma error argument path to the relevant field name
586+
* @param {string} rawPath
587+
* @returns {string|null}
588+
*/
589+
normalizeErrorFieldPath(rawPath) {
590+
if (!rawPath || typeof rawPath !== 'string') {
591+
return null;
592+
}
593+
594+
const segments = rawPath
595+
.replace(/\[\d+\]/g, '.') // Treat array indices as segment separators
596+
.split('.')
597+
.map(segment => segment.trim())
598+
.filter(Boolean);
599+
600+
if (!segments.length) {
601+
return null;
602+
}
603+
604+
return segments[segments.length - 1];
605+
}
606+
607+
/**
608+
* Convert string representations of numeric values to their proper numeric types
609+
* @param {Object} upsertData
610+
* @param {Map<string, string>} fieldTypeMap
611+
* @returns {{data: Object, modified: boolean, convertedFields: string[]}}
612+
*/
613+
convertNumericStringFields(upsertData, fieldTypeMap) {
614+
if (!upsertData || !(fieldTypeMap instanceof Map)) {
615+
return { data: upsertData, modified: false, convertedFields: [] };
616+
}
617+
618+
if (!fieldTypeMap.size) {
619+
return { data: upsertData, modified: false, convertedFields: [] };
620+
}
621+
622+
const targetMap = new Map();
623+
for (const [field, type] of fieldTypeMap.entries()) {
624+
if (typeof field === 'string' && field) {
625+
targetMap.set(field, (type || '').toUpperCase());
626+
}
627+
}
628+
629+
if (!targetMap.size) {
630+
return { data: upsertData, modified: false, convertedFields: [] };
631+
}
632+
633+
const convertedFields = new Set();
634+
const convertValue = (value) => {
635+
if (value === null || value === undefined) {
636+
return value;
637+
}
638+
639+
if (value instanceof Date) {
640+
return value;
641+
}
642+
643+
if (Prisma?.Decimal && value instanceof Prisma.Decimal) {
644+
return value;
645+
}
646+
647+
if (Array.isArray(value)) {
648+
let changed = false;
649+
const result = value.map(item => {
650+
const converted = convertValue(item);
651+
if (converted !== item) {
652+
changed = true;
653+
}
654+
return converted;
655+
});
656+
657+
return changed ? result : value;
658+
}
659+
660+
if (typeof value === 'object') {
661+
let changed = false;
662+
const result = {};
663+
664+
for (const [key, entry] of Object.entries(value)) {
665+
let newEntry = entry;
666+
const expectedType = targetMap.get(key);
667+
668+
if (expectedType && typeof entry === 'string') {
669+
const converted = this.tryConvertNumericString(entry, expectedType);
670+
if (converted !== null) {
671+
newEntry = converted;
672+
changed = true;
673+
convertedFields.add(`${key} (${expectedType})`);
674+
}
675+
}
676+
677+
const nested = convertValue(newEntry);
678+
if (nested !== newEntry) {
679+
newEntry = nested;
680+
changed = true;
681+
}
682+
683+
result[key] = newEntry;
684+
}
685+
686+
return changed ? result : value;
687+
}
688+
689+
return value;
690+
};
691+
692+
const transformedWhere = convertValue(upsertData.where);
693+
const transformedUpdate = convertValue(upsertData.update);
694+
const transformedCreate = convertValue(upsertData.create);
695+
696+
const modified =
697+
transformedWhere !== upsertData.where ||
698+
transformedUpdate !== upsertData.update ||
699+
transformedCreate !== upsertData.create;
700+
701+
if (!modified) {
702+
return { data: upsertData, modified: false, convertedFields: [] };
703+
}
704+
705+
return {
706+
data: {
707+
...upsertData,
708+
where: transformedWhere,
709+
update: transformedUpdate,
710+
create: transformedCreate
711+
},
712+
modified: true,
713+
convertedFields: Array.from(convertedFields)
714+
};
715+
}
716+
717+
/**
718+
* Attempt to convert a numeric-looking string into the proper numeric type
719+
* @param {string} value
720+
* @param {string} expectedType
721+
* @returns {number|BigInt|Prisma.Decimal|null}
722+
*/
723+
tryConvertNumericString(value, expectedType) {
724+
if (typeof value !== 'string') {
725+
return null;
726+
}
727+
728+
const trimmed = value.trim();
729+
if (!trimmed.length) {
730+
return null;
731+
}
732+
733+
const type = (expectedType || '').toUpperCase();
734+
const integerPattern = /^[+-]?\d+$/;
735+
const floatPattern = /^[+-]?(?:\d+(\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?$/;
736+
737+
if (type === 'BIGINT') {
738+
if (!integerPattern.test(trimmed)) {
739+
return null;
740+
}
741+
try {
742+
return BigInt(trimmed);
743+
} catch (err) {
744+
return null;
745+
}
746+
}
747+
748+
if (type === 'INT' || type === 'SMALLINT') {
749+
if (!integerPattern.test(trimmed)) {
750+
return null;
751+
}
752+
const intValue = Number(trimmed);
753+
if (!Number.isFinite(intValue) || !Number.isInteger(intValue)) {
754+
return null;
755+
}
756+
return intValue;
757+
}
758+
759+
if (type === 'FLOAT' || type === 'DOUBLE' || type === 'REAL') {
760+
if (!floatPattern.test(trimmed)) {
761+
return null;
762+
}
763+
const floatValue = Number(trimmed);
764+
return Number.isFinite(floatValue) ? floatValue : null;
765+
}
766+
767+
if (type === 'DECIMAL' || type === 'NUMERIC') {
768+
if (!floatPattern.test(trimmed)) {
769+
return null;
770+
}
771+
if (Prisma?.Decimal) {
772+
try {
773+
return new Prisma.Decimal(trimmed);
774+
} catch (err) {
775+
// Fallback to native number if Decimal instantiation fails
776+
}
777+
}
778+
const decimalValue = Number(trimmed);
779+
return Number.isFinite(decimalValue) ? decimalValue : null;
780+
}
781+
782+
if (integerPattern.test(trimmed)) {
783+
const intValue = Number(trimmed);
784+
if (Number.isFinite(intValue) && Number.isInteger(intValue)) {
785+
return intValue;
786+
}
787+
}
788+
789+
if (floatPattern.test(trimmed)) {
790+
const floatValue = Number(trimmed);
791+
return Number.isFinite(floatValue) ? floatValue : null;
792+
}
793+
794+
return null;
795+
}
796+
525797
/**
526798
* Sanitize integer overflow values from the upsert payload
527799
* @param {Object} upsertData

0 commit comments

Comments
 (0)