4444 MYSQL_INSERT_METHOD ,
4545 MYSQL_TEXT_COLUMN_TYPES ,
4646 MYSQL_TEXT_COLUMN_TYPES_WITH_JSON ,
47+ check_mysql_current_timestamp_datetime_support ,
48+ check_mysql_expression_defaults_support ,
49+ check_mysql_fractional_seconds_support ,
4750 check_mysql_fulltext_support ,
4851 check_mysql_json_support ,
4952 check_mysql_values_alias_support ,
@@ -59,6 +62,18 @@ class SQLite3toMySQL(SQLite3toMySQLAttributes):
5962 COLUMN_LENGTH_PATTERN : t .Pattern [str ] = re .compile (r"\(\d+\)" )
6063 COLUMN_PRECISION_AND_SCALE_PATTERN : t .Pattern [str ] = re .compile (r"\(\d+,\d+\)" )
6164 COLUMN_UNSIGNED_PATTERN : t .Pattern [str ] = re .compile (r"\bUNSIGNED\b" , re .IGNORECASE )
65+ CURRENT_TS : t .Pattern [str ] = re .compile (r"^CURRENT_TIMESTAMP(?:\s*\(\s*\))?$" , re .IGNORECASE )
66+ CURRENT_DATE : t .Pattern [str ] = re .compile (r"^CURRENT_DATE(?:\s*\(\s*\))?$" , re .IGNORECASE )
67+ CURRENT_TIME : t .Pattern [str ] = re .compile (r"^CURRENT_TIME(?:\s*\(\s*\))?$" , re .IGNORECASE )
68+ SQLITE_NOW_FUNC : t .Pattern [str ] = re .compile (
69+ r"^(datetime|date|time)\s*\(\s*'now'(?:\s*,\s*'(localtime|utc)')?\s*\)$" ,
70+ re .IGNORECASE ,
71+ )
72+ STRFTIME_NOW : t .Pattern [str ] = re .compile (
73+ r"^strftime\s*\(\s*'([^']+)'\s*,\s*'now'(?:\s*,\s*'(localtime|utc)')?\s*\)$" ,
74+ re .IGNORECASE ,
75+ )
76+ NUMERIC_LITERAL_PATTERN : t .Pattern [str ] = re .compile (r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$" )
6277
6378 MYSQL_CONNECTOR_VERSION : version .Version = version .parse (mysql_connector_version_string )
6479
@@ -194,6 +209,9 @@ def __init__(self, **kwargs: Unpack[SQLite3toMySQLParams]):
194209 self ._mysql_version = self ._get_mysql_version ()
195210 self ._mysql_json_support = check_mysql_json_support (self ._mysql_version )
196211 self ._mysql_fulltext_support = check_mysql_fulltext_support (self ._mysql_version )
212+ self ._allow_expr_defaults = check_mysql_expression_defaults_support (self ._mysql_version )
213+ self ._allow_current_ts_dt = check_mysql_current_timestamp_datetime_support (self ._mysql_version )
214+ self ._allow_fsp = check_mysql_fractional_seconds_support (self ._mysql_version )
197215
198216 if self ._use_fulltext and not self ._mysql_fulltext_support :
199217 raise ValueError ("Your MySQL version does not support InnoDB FULLTEXT indexes!" )
@@ -339,12 +357,157 @@ def _translate_type_from_sqlite_to_mysql(self, column_type: str) -> str:
339357 return self ._mysql_string_type
340358 return full_column_type
341359
360+ @staticmethod
361+ def _strip_wrapping_parentheses (expr : str ) -> str :
362+ """Remove one or more layers of *fully wrapping* parentheses around an expression.
363+
364+ Only strip if the matching ')' for the very first '(' is the final character
365+ of the string. This avoids corrupting expressions like "(a) + (b)".
366+ """
367+ s : str = expr .strip ()
368+ while s .startswith ("(" ):
369+ depth : int = 0
370+ match_idx : int = - 1
371+ i : int
372+ ch : str
373+ # Find the matching ')' for the '(' at index 0
374+ for i , ch in enumerate (s ):
375+ if ch == "(" :
376+ depth += 1
377+ elif ch == ")" :
378+ depth -= 1
379+ if depth == 0 :
380+ match_idx = i
381+ break
382+ # Only strip if the match closes at the very end
383+ if match_idx == len (s ) - 1 :
384+ s = s [1 :match_idx ].strip ()
385+ # continue to try stripping more fully-wrapping layers
386+ continue
387+ # Not a fully-wrapped expression; stop
388+ break
389+ return s
390+
391+ def _translate_default_for_mysql (self , column_type : str , default : str ) -> str :
392+ """Translate SQLite DEFAULT expression to a MySQL-compatible one for common cases.
393+
394+ Returns a string suitable to append after "DEFAULT ", without the word itself.
395+ Keeps literals as-is, maps `CURRENT_*`/`datetime('now')`/`strftime(...,'now')` to
396+ the appropriate MySQL `CURRENT_*` functions, preserves fractional seconds if the
397+ column type declares a precision, and normalizes booleans to 0/1.
398+ """
399+ raw : str = default .strip ()
400+ if not raw :
401+ return raw
402+
403+ s : str = self ._strip_wrapping_parentheses (raw )
404+ u : str = s .upper ()
405+
406+ # NULL passthrough
407+ if u == "NULL" :
408+ return "NULL"
409+
410+ # Determine base data type
411+ match : t .Optional [re .Match [str ]] = self ._valid_column_type (column_type )
412+ base : str = match .group (0 ).upper () if match else column_type .upper ()
413+
414+ # TIMESTAMP: allow CURRENT_TIMESTAMP across versions; preserve FSP only if supported
415+ if base .startswith ("TIMESTAMP" ) and (
416+ self .CURRENT_TS .match (s )
417+ or (self .SQLITE_NOW_FUNC .match (s ) and s .lower ().startswith ("datetime" ))
418+ or self .STRFTIME_NOW .match (s )
419+ ):
420+ len_match : t .Optional [re .Match [str ]] = self .COLUMN_LENGTH_PATTERN .search (column_type )
421+ fsp : str = ""
422+ if self ._allow_fsp and len_match :
423+ try :
424+ n = int (len_match .group (0 ).strip ("()" ))
425+ except ValueError :
426+ n = None
427+ if n is not None and 0 < n <= 6 :
428+ fsp = f"({ n } )"
429+ return f"CURRENT_TIMESTAMP{ fsp } "
430+
431+ # DATETIME: require server support, otherwise omit the DEFAULT
432+ if base .startswith ("DATETIME" ) and (
433+ self .CURRENT_TS .match (s )
434+ or (self .SQLITE_NOW_FUNC .match (s ) and s .lower ().startswith ("datetime" ))
435+ or self .STRFTIME_NOW .match (s )
436+ ):
437+ if not self ._allow_current_ts_dt :
438+ return ""
439+ len_match = self .COLUMN_LENGTH_PATTERN .search (column_type )
440+ fsp = ""
441+ if self ._allow_fsp and len_match :
442+ try :
443+ n = int (len_match .group (0 ).strip ("()" ))
444+ except ValueError :
445+ n = None
446+ if n is not None and 0 < n <= 6 :
447+ fsp = f"({ n } )"
448+ return f"CURRENT_TIMESTAMP{ fsp } "
449+
450+ # DATE
451+ if (
452+ base .startswith ("DATE" )
453+ and (
454+ self .CURRENT_DATE .match (s )
455+ or self .CURRENT_TS .match (s ) # map CURRENT_TIMESTAMP → CURRENT_DATE for DATE
456+ or (self .SQLITE_NOW_FUNC .match (s ) and s .lower ().startswith ("date" ))
457+ or self .STRFTIME_NOW .match (s )
458+ )
459+ and self ._allow_expr_defaults
460+ ):
461+ # Too old for expression defaults on DATE → fall back
462+ return "CURRENT_DATE"
463+
464+ # TIME
465+ if (
466+ base .startswith ("TIME" )
467+ and (
468+ self .CURRENT_TIME .match (s )
469+ or self .CURRENT_TS .match (s ) # map CURRENT_TIMESTAMP → CURRENT_TIME for TIME
470+ or (self .SQLITE_NOW_FUNC .match (s ) and s .lower ().startswith ("time" ))
471+ or self .STRFTIME_NOW .match (s )
472+ )
473+ and self ._allow_expr_defaults
474+ ):
475+ # Too old for expression defaults on TIME → fall back
476+ len_match = self .COLUMN_LENGTH_PATTERN .search (column_type )
477+ fsp = ""
478+ if self ._allow_fsp and len_match :
479+ try :
480+ n = int (len_match .group (0 ).strip ("()" ))
481+ except ValueError :
482+ n = None
483+ if n is not None and 0 < n <= 6 :
484+ fsp = f"({ n } )"
485+ return f"CURRENT_TIME{ fsp } "
486+
487+ # Booleans (store as 0/1)
488+ if base in {"BOOL" , "BOOLEAN" } or base .startswith ("TINYINT" ):
489+ if u in {"TRUE" , "'TRUE'" , '"TRUE"' }:
490+ return "1"
491+ if u in {"FALSE" , "'FALSE'" , '"FALSE"' }:
492+ return "0"
493+
494+ # Numeric literals (possibly wrapped)
495+ if self .NUMERIC_LITERAL_PATTERN .match (s ):
496+ return s
497+
498+ # Quoted strings and hex blobs pass through as-is
499+ if (s .startswith ("'" ) and s .endswith ("'" )) or (s .startswith ('"' ) and s .endswith ('"' )) or u .startswith ("X'" ):
500+ return s
501+
502+ # Fallback: return stripped expression (MySQL 8.0.13+ allows expression defaults)
503+ return s
504+
342505 @classmethod
343506 def _column_type_length (cls , column_type : str , default : t .Optional [t .Union [str , int , float ]] = None ) -> str :
344507 suffix : t .Optional [t .Match [str ]] = cls .COLUMN_LENGTH_PATTERN .search (column_type )
345508 if suffix :
346509 return suffix .group (0 )
347- if default :
510+ if default is not None :
348511 return f"({ default } )"
349512 return ""
350513
@@ -386,18 +549,22 @@ def _create_table(self, table_name: str, transfer_rowid: bool = False) -> None:
386549 column ["pk" ] > 0 and column_type .startswith (("INT" , "BIGINT" )) and not compound_primary_key
387550 )
388551
552+ # Build DEFAULT clause safely (preserve falsy defaults like 0/'')
553+ default_clause : str = ""
554+ if (
555+ column ["dflt_value" ] is not None
556+ and column_type not in MYSQL_COLUMN_TYPES_WITHOUT_DEFAULT
557+ and not auto_increment
558+ ):
559+ td : str = self ._translate_default_for_mysql (column_type , str (column ["dflt_value" ]))
560+ if td != "" :
561+ default_clause = "DEFAULT " + td
389562 sql += " `{name}` {type} {notnull} {default} {auto_increment}, " .format (
390563 name = mysql_safe_name ,
391564 type = column_type ,
392565 notnull = "NOT NULL" if column ["notnull" ] or column ["pk" ] else "NULL" ,
393566 auto_increment = "AUTO_INCREMENT" if auto_increment else "" ,
394- default = (
395- "DEFAULT " + column ["dflt_value" ]
396- if column ["dflt_value" ]
397- and column_type not in MYSQL_COLUMN_TYPES_WITHOUT_DEFAULT
398- and not auto_increment
399- else ""
400- ),
567+ default = default_clause ,
401568 )
402569
403570 if column ["pk" ] > 0 :
0 commit comments