mirror of
https://github.com/dimitri/pgloader.git
synced 2025-08-11 00:36:59 +02:00
In passing, fix a bug in the previous commit where left-over code would cancel the whole new parsing code for advanced source fields options.
428 lines
17 KiB
Common Lisp
428 lines
17 KiB
Common Lisp
;;;
|
||
;;; Tools to query the MySQL Schema to reproduce in PostgreSQL
|
||
;;;
|
||
|
||
(in-package pgloader.mysql)
|
||
|
||
(defvar *connection* nil "Current MySQL connection")
|
||
|
||
|
||
;;;
|
||
;;; Specific implementation of schema migration, see the API in
|
||
;;; src/pgsql/schema.lisp
|
||
;;;
|
||
(defstruct (mysql-column
|
||
(:constructor make-mysql-column
|
||
(table-name name dtype ctype default nullable extra)))
|
||
table-name name dtype ctype default nullable extra)
|
||
|
||
(defmethod format-pgsql-column ((col mysql-column) &key identifier-case)
|
||
"Return a string representing the PostgreSQL column definition."
|
||
(let* ((column-name
|
||
(apply-identifier-case (mysql-column-name col) identifier-case))
|
||
(type-definition
|
||
(with-slots (table-name name dtype ctype default nullable extra)
|
||
col
|
||
(cast table-name name dtype ctype default nullable extra))))
|
||
(format nil "~a ~22t ~a" column-name type-definition)))
|
||
|
||
(defmethod format-extra-type ((col mysql-column)
|
||
&key identifier-case include-drop)
|
||
"Return a string representing the extra needed PostgreSQL CREATE TYPE
|
||
statement, if such is needed"
|
||
(let ((dtype (mysql-column-dtype col)))
|
||
(when (or (string-equal "enum" dtype)
|
||
(string-equal "set" dtype))
|
||
(list
|
||
(when include-drop
|
||
(let* ((type-name
|
||
(get-enum-type-name (mysql-column-table-name col)
|
||
(mysql-column-name col)
|
||
identifier-case)))
|
||
(format nil "DROP TYPE IF EXISTS ~a;" type-name)))
|
||
|
||
(get-create-enum (mysql-column-table-name col)
|
||
(mysql-column-name col)
|
||
(mysql-column-ctype col)
|
||
:identifier-case identifier-case)))))
|
||
|
||
|
||
;;;
|
||
;;; General utility to manage MySQL connection
|
||
;;;
|
||
(defun mysql-query (query &key row-fn (as-text t) (result-type 'list))
|
||
"Execute given QUERY within the current *connection*, and set proper
|
||
defaults for pgloader."
|
||
(qmynd:mysql-query *connection* query
|
||
:row-fn row-fn
|
||
:as-text as-text
|
||
:result-type result-type))
|
||
|
||
(defmacro with-mysql-connection ((&optional (dbname *my-dbname*)) &body forms)
|
||
"Connect to MySQL, use given DBNAME as the current database if provided,
|
||
and execute FORMS in a protected way so that we always disconnect when
|
||
done.
|
||
|
||
Connection parameters are *myconn-host*, *myconn-port*, *myconn-user* and
|
||
*myconn-pass*."
|
||
`(let* ((dbname (or ,dbname *my-dbname*))
|
||
(*connection*
|
||
(if (and (consp *myconn-host*) (eq :unix (car *myconn-host*)))
|
||
(qmynd:mysql-local-connect :path (cdr *myconn-host*)
|
||
:username *myconn-user*
|
||
:password *myconn-pass*
|
||
:database dbname)
|
||
(qmynd:mysql-connect :host *myconn-host*
|
||
:port *myconn-port*
|
||
:username *myconn-user*
|
||
:password *myconn-pass*
|
||
:database dbname))))
|
||
(unwind-protect
|
||
(progn ,@forms)
|
||
(qmynd:mysql-disconnect *connection*))))
|
||
|
||
;;;
|
||
;;; Function for accessing the MySQL catalogs, implementing auto-discovery.
|
||
;;;
|
||
;;; Interactive use only, will create its own database connection.
|
||
;;;
|
||
(defun list-databases ()
|
||
"Connect to a local database and get the database list"
|
||
(with-mysql-connection ()
|
||
(mysql-query "show databases")))
|
||
|
||
(defun list-tables (dbname)
|
||
"Return a flat list of all the tables names known in given DATABASE"
|
||
(with-mysql-connection (dbname)
|
||
(mysql-query (format nil "
|
||
select table_name
|
||
from information_schema.tables
|
||
where table_schema = '~a' and table_type = 'BASE TABLE'
|
||
order by table_name" dbname))))
|
||
|
||
(defun list-views (dbname &key only-tables)
|
||
"Return a flat list of all the view names and definitions known in given DBNAME"
|
||
(with-mysql-connection (dbname)
|
||
(mysql-query (format nil "
|
||
select table_name, view_definition
|
||
from information_schema.views
|
||
where table_schema = '~a'
|
||
~@[and table_name in (~{'~a'~^,~})~]
|
||
order by table_name" dbname only-tables))))
|
||
|
||
|
||
;;;
|
||
;;; Those functions are to be called from withing an already established
|
||
;;; MySQL Connection.
|
||
;;;
|
||
;;; Handle MATERIALIZE VIEWS sections, where we need to create the views in
|
||
;;; the MySQL database before being able to process them.
|
||
;;;
|
||
(defun create-my-views (views-alist)
|
||
"VIEWS-ALIST associates view names with their SQL definition, which might
|
||
be empty for already existing views. Create only the views for which we
|
||
have an SQL definition."
|
||
(unless (eq :all views-alist)
|
||
(let ((views (remove-if #'null views-alist :key #'cdr)))
|
||
(when views
|
||
(loop for (name . def) in views
|
||
for sql = (format nil "CREATE VIEW ~a AS ~a" name def)
|
||
do
|
||
(log-message :info "MySQL: ~a" sql)
|
||
(mysql-query sql))))))
|
||
|
||
(defun drop-my-views (views-alist)
|
||
"See `create-my-views' for VIEWS-ALIST description. This time we DROP the
|
||
views to clean out after our work."
|
||
(unless (eq :all views-alist)
|
||
(let ((views (remove-if #'null views-alist :key #'cdr)))
|
||
(when views
|
||
(let ((sql
|
||
(format nil "DROP VIEW ~{~a~^, ~};" (mapcar #'car views))))
|
||
(log-message :info "MySQL: ~a" sql)
|
||
(mysql-query sql))))))
|
||
|
||
|
||
;;;
|
||
;;; Those functions are to be called from withing an already established
|
||
;;; MySQL Connection.
|
||
;;;
|
||
;;; Tools to get MySQL table and columns definitions and transform them to
|
||
;;; PostgreSQL CREATE TABLE statements, and run those.
|
||
;;;
|
||
(defvar *table-type* '((:table . "BASE TABLE")
|
||
(:view . "VIEW"))
|
||
"Associate internal table type symbol with what's found in MySQL
|
||
information_schema.tables.table_type column.")
|
||
|
||
(defun filter-list-to-where-clause (filter-list &optional not)
|
||
"Given an INCLUDING or EXCLUDING clause, turn it into a MySQL WHERE clause."
|
||
(mapcar (lambda (filter)
|
||
(typecase filter
|
||
(string (format nil "~:[~;!~]= '~a'" not filter))
|
||
(cons (format nil "~:[~;NOT ~]REGEXP '~a'" not (cadr filter)))))
|
||
filter-list))
|
||
|
||
(defun list-all-columns (&key
|
||
(dbname *my-dbname*)
|
||
(table-type :table)
|
||
only-tables
|
||
including
|
||
excluding
|
||
&aux
|
||
(table-type-name (cdr (assoc table-type *table-type*))))
|
||
"Get the list of MySQL column names per table."
|
||
(loop
|
||
with schema = nil
|
||
for (table-name name dtype ctype default nullable extra)
|
||
in
|
||
(mysql-query (format nil "
|
||
select c.table_name, c.column_name,
|
||
c.data_type, c.column_type, c.column_default,
|
||
c.is_nullable, c.extra
|
||
from information_schema.columns c
|
||
join information_schema.tables t using(table_schema, table_name)
|
||
where c.table_schema = '~a' and t.table_type = '~a'
|
||
~:[~*~;and table_name in (~{'~a'~^,~})~]
|
||
~:[~*~;and (~{table_name ~a~^ or ~})~]
|
||
~:[~*~;and (~{table_name ~a~^ and ~})~]
|
||
order by table_name, ordinal_position"
|
||
dbname
|
||
table-type-name
|
||
only-tables ; do we print the clause?
|
||
only-tables
|
||
including ; do we print the clause?
|
||
(filter-list-to-where-clause including)
|
||
excluding ; do we print the clause?
|
||
(filter-list-to-where-clause excluding t)))
|
||
do
|
||
(let ((entry (assoc table-name schema :test 'equal))
|
||
(column
|
||
(make-mysql-column
|
||
table-name name dtype ctype default nullable extra)))
|
||
(if entry
|
||
(push column (cdr entry))
|
||
(push (cons table-name (list column)) schema)))
|
||
finally
|
||
;; we did push, we need to reverse here
|
||
(return (loop
|
||
for name in (if only-tables only-tables
|
||
(reverse (mapcar #'car schema)))
|
||
for cols = (cdr (assoc name schema :test #'string=))
|
||
collect (cons name (reverse cols))))))
|
||
|
||
(defun list-all-indexes (&key
|
||
(dbname *my-dbname*)
|
||
only-tables
|
||
including
|
||
excluding)
|
||
"Get the list of MySQL index definitions per table."
|
||
(loop
|
||
with schema = nil
|
||
for (table-name name non-unique cols)
|
||
in (mysql-query (format nil "
|
||
SELECT table_name, index_name, non_unique,
|
||
cast(GROUP_CONCAT(column_name order by seq_in_index) as char)
|
||
FROM information_schema.statistics
|
||
WHERE table_schema = '~a'
|
||
~:[~*~;and table_name in (~{'~a'~^,~})~]
|
||
~:[~*~;and (~{table_name ~a~^ or ~})~]
|
||
~:[~*~;and (~{table_name ~a~^ and ~})~]
|
||
GROUP BY table_name, index_name;"
|
||
dbname
|
||
only-tables ; do we print the clause?
|
||
only-tables
|
||
including ; do we print the clause?
|
||
(filter-list-to-where-clause including)
|
||
excluding ; do we print the clause?
|
||
(filter-list-to-where-clause excluding t)))
|
||
do (let ((entry (assoc table-name schema :test 'equal))
|
||
(index
|
||
(make-pgsql-index :name name
|
||
:primary (string= name "PRIMARY")
|
||
:table-name table-name
|
||
:unique (not (string= "1" non-unique))
|
||
:columns (sq:split-sequence #\, cols))))
|
||
(if entry
|
||
(push index (cdr entry))
|
||
(push (cons table-name (list index)) schema)))
|
||
finally
|
||
;; we did push, we need to reverse here
|
||
(return (reverse (loop
|
||
for (name . indexes) in schema
|
||
collect (cons name (reverse indexes)))))))
|
||
|
||
(defun set-table-oids (all-indexes &key identifier-case)
|
||
"MySQL allows using the same index name against separate tables, which
|
||
PostgreSQL forbids. To get unicity in index names without running out of
|
||
characters (we are allowed only 63), we use the table OID instead.
|
||
|
||
This function grabs the table OIDs in the PostgreSQL database and update
|
||
the definitions with them."
|
||
(let* ((table-names (mapcar #'(lambda (table-name)
|
||
(apply-identifier-case table-name identifier-case))
|
||
(mapcar #'car all-indexes)))
|
||
(table-oids (pgloader.pgsql:list-table-oids table-names)))
|
||
(loop for (table-name-raw . indexes) in all-indexes
|
||
for table-name = (apply-identifier-case table-name-raw identifier-case)
|
||
for table-oid = (cdr (assoc table-name table-oids :test #'string=))
|
||
unless table-oid do (error "OID not found for ~s." table-name)
|
||
do (loop for index in indexes
|
||
do (setf (pgloader.pgsql::pgsql-index-table-oid index) table-oid)))))
|
||
|
||
;;;
|
||
;;; MySQL Foreign Keys
|
||
;;;
|
||
(defun list-all-fkeys (&key
|
||
(dbname *my-dbname*)
|
||
only-tables
|
||
including
|
||
excluding)
|
||
"Get the list of MySQL Foreign Keys definitions per table."
|
||
(loop
|
||
with schema = nil
|
||
for (table-name name ftable cols fcols)
|
||
in (mysql-query (format nil "
|
||
SELECT i.table_name, i.constraint_name, k.referenced_table_name ft,
|
||
|
||
group_concat( k.column_name
|
||
order by k.ordinal_position) as cols,
|
||
|
||
group_concat( k.referenced_column_name
|
||
order by k.position_in_unique_constraint) as fcols
|
||
|
||
FROM information_schema.table_constraints i
|
||
LEFT JOIN information_schema.key_column_usage k
|
||
USING (table_schema, table_name, constraint_name)
|
||
|
||
WHERE i.table_schema = '~a'
|
||
AND k.referenced_table_schema = '~a'
|
||
AND i.constraint_type = 'FOREIGN KEY'
|
||
~:[~*~;and table_name in (~{'~a'~^,~})~]
|
||
~:[~*~;and (~{table_name ~a~^ or ~})~]
|
||
~:[~*~;and (~{table_name ~a~^ and ~})~]
|
||
|
||
GROUP BY table_name, constraint_name, ft;"
|
||
dbname dbname
|
||
only-tables ; do we print the clause?
|
||
only-tables
|
||
including ; do we print the clause?
|
||
(filter-list-to-where-clause including)
|
||
excluding ; do we print the clause?
|
||
(filter-list-to-where-clause excluding t)))
|
||
do (let ((entry (assoc table-name schema :test 'equal))
|
||
(fk
|
||
(make-pgsql-fkey :name name
|
||
:table-name table-name
|
||
:columns (sq:split-sequence #\, cols)
|
||
:foreign-table ftable
|
||
:foreign-columns (sq:split-sequence #\, fcols))))
|
||
(if entry
|
||
(push fk (cdr entry))
|
||
(push (cons table-name (list fk)) schema)))
|
||
finally
|
||
;; we did push, we need to reverse here
|
||
(return (reverse (loop
|
||
for (name . fks) in schema
|
||
collect (cons name (reverse fks)))))))
|
||
|
||
(defun drop-pgsql-fkeys (all-fkeys &key (dbname *pg-dbname*) identifier-case)
|
||
"Drop all Foreign Key Definitions given, to prepare for a clean run."
|
||
(let ((all-pgsql-fkeys (list-tables-and-fkeys dbname)))
|
||
(loop for (table-name . fkeys) in all-fkeys
|
||
do
|
||
(loop for fkey in fkeys
|
||
for sql = (format-pgsql-drop-fkey fkey
|
||
:all-pgsql-fkeys all-pgsql-fkeys
|
||
:identifier-case identifier-case)
|
||
when sql
|
||
do
|
||
(log-message :notice "~a;" sql)
|
||
(pgsql-execute sql)))))
|
||
|
||
(defun create-pgsql-fkeys (all-fkeys
|
||
&key
|
||
(dbname *pg-dbname*)
|
||
state
|
||
identifier-case
|
||
(label "Foreign Keys"))
|
||
"Actually create the Foreign Key References that where declared in the
|
||
MySQL database"
|
||
(pgstate-add-table state dbname label)
|
||
(loop for (table-name . fkeys) in all-fkeys
|
||
do (loop for fkey in fkeys
|
||
for sql =
|
||
(format-pgsql-create-fkey fkey :identifier-case identifier-case)
|
||
do
|
||
(log-message :notice "~a;" sql)
|
||
(pgsql-execute-with-timing dbname "Foreign Keys" sql state))))
|
||
|
||
|
||
;;;
|
||
;;; Sequences
|
||
;;;
|
||
(defun reset-pgsql-sequences (all-columns
|
||
&key (dbname *pg-dbname*) state identifier-case)
|
||
"Reset all sequences created during this MySQL migration."
|
||
(let ((tables
|
||
(mapcar
|
||
(lambda (name) (apply-identifier-case name identifier-case))
|
||
(mapcar #'car all-columns))))
|
||
(log-message :notice "Reset sequences")
|
||
(with-stats-collection ("Reset Sequences"
|
||
:dbname dbname
|
||
:use-result-as-rows t
|
||
:state state)
|
||
(pgloader.pgsql:reset-all-sequences dbname :tables tables))))
|
||
|
||
|
||
;;;
|
||
;;; Tools to handle row queries, issuing separate is null statements and
|
||
;;; handling of geometric data types.
|
||
;;;
|
||
(defun get-column-sql-expression (name type)
|
||
"Return per-TYPE SQL expression to use given a column NAME.
|
||
|
||
Mostly we just use the name, but in case of POINT we need to use
|
||
astext(name)."
|
||
(case (intern (string-upcase type) "KEYWORD")
|
||
(:point (format nil "astext(`~a`) as `~a`" name name))
|
||
(t (format nil "`~a`" name))))
|
||
|
||
(defun get-column-list (dbname table-name)
|
||
"Some MySQL datatypes have a meaningless default output representation, we
|
||
need to process them on the SQL side (geometric data types).
|
||
|
||
This function assumes a valid connection to the MySQL server has been
|
||
established already."
|
||
(loop
|
||
for (name type) in (mysql-query (format nil "
|
||
select column_name, data_type
|
||
from information_schema.columns
|
||
where table_schema = '~a' and table_name = '~a'
|
||
order by ordinal_position" dbname table-name)
|
||
:result-type 'list)
|
||
collect (get-column-sql-expression name type)))
|
||
|
||
(declaim (inline fix-nulls))
|
||
|
||
(defun fix-nulls (row nulls)
|
||
"Given a cl-mysql row result and a nulls list as from
|
||
get-column-list-with-is-nulls, replace NIL with empty strings with when
|
||
we know from the added 'foo is null' that the actual value IS NOT NULL.
|
||
|
||
See http://bugs.mysql.com/bug.php?id=19564 for context."
|
||
(loop
|
||
for (current-col next-col) on row
|
||
for (current-null next-null) on nulls
|
||
;; next-null tells us if next column is an "is-null" col
|
||
;; when next-null is true, next-col is true if current-col is actually null
|
||
for is-null = (and next-null (string= next-col "1"))
|
||
for is-empty = (and next-null (string= next-col "0") (null current-col))
|
||
;; don't collect columns we added, e.g. "column_name is not null"
|
||
when (not current-null)
|
||
collect (cond (is-null :null)
|
||
(is-empty "")
|
||
(t current-col))))
|
||
|