pgloader/src/sources/mysql/mysql-schema.lisp
Dimitri Fontaine 2369a142a7 Refactor source code organisation.
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.
2014-10-01 23:20:24 +02:00

428 lines
17 KiB
Common Lisp
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

;;;
;;; 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))))