001/*
002 * (C) Copyright 2006-2014 Nuxeo SA (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     George Lefter
018 *     Florent Guillaume
019 *     Julien Carsique
020 */
021package org.nuxeo.ecm.directory.sql;
022
023import static org.nuxeo.ecm.directory.BaseDirectoryDescriptor.CREATE_TABLE_POLICY_NEVER;
024import static org.nuxeo.ecm.directory.BaseDirectoryDescriptor.CREATE_TABLE_POLICY_ON_MISSING_COLUMNS;
025
026import java.sql.Connection;
027import java.sql.DatabaseMetaData;
028import java.sql.ResultSet;
029import java.sql.SQLException;
030import java.sql.Statement;
031import java.util.HashSet;
032import java.util.Set;
033
034import org.apache.logging.log4j.LogManager;
035import org.apache.logging.log4j.Logger;
036import org.nuxeo.ecm.core.api.ConcurrentUpdateException;
037import org.nuxeo.ecm.core.storage.sql.ColumnType;
038import org.nuxeo.ecm.core.storage.sql.jdbc.JDBCLogger;
039import org.nuxeo.ecm.core.storage.sql.jdbc.db.Column;
040import org.nuxeo.ecm.core.storage.sql.jdbc.db.Table;
041import org.nuxeo.ecm.core.storage.sql.jdbc.db.TableImpl;
042import org.nuxeo.ecm.core.storage.sql.jdbc.dialect.Dialect;
043import org.nuxeo.ecm.directory.DirectoryException;
044
045public class SQLHelper {
046
047    private static final Logger log = LogManager.getLogger(SQLHelper.class);
048
049    private static final Object DIRECTORY_INIT_LOCK = new Object();
050
051    private final Table table;
052
053    private final String tableName;
054
055    private final Connection connection;
056
057    private final String policy;
058
059    private JDBCLogger logger = new JDBCLogger("SQLDirectory");
060
061    public SQLHelper(Connection connection, Table table, String policy) {
062        this.table = table;
063        this.connection = connection;
064        this.policy = policy;
065        tableName = table.getPhysicalName();
066    }
067
068    /**
069     * Sets up the table without loading the data in it.
070     *
071     * @return {@code true} if table has been created
072     */
073    public boolean setupTable() {
074        log.debug("setting up table {}, policy={}", tableName, policy);
075        if (policy.equals(CREATE_TABLE_POLICY_NEVER)) {
076            log.debug("policy='{}', skipping setup", CREATE_TABLE_POLICY_NEVER);
077            return false;
078        }
079        synchronized (DIRECTORY_INIT_LOCK) {
080            boolean tableExists = tableExists();
081            // check the field names match the column names
082            if (policy.equals(CREATE_TABLE_POLICY_ON_MISSING_COLUMNS) && tableExists) {
083                if (hasMatchingColumns()) {
084                    // all required columns were found
085                    log.debug("policy='{}' and all column matched, skipping data load",
086                            CREATE_TABLE_POLICY_ON_MISSING_COLUMNS);
087                } else {
088                    log.debug("policy='{}' and some columns are missing", CREATE_TABLE_POLICY_ON_MISSING_COLUMNS);
089                    addMissingColumns();
090                }
091                return false;
092            } // else policy=always or table doesn't exist
093            try {
094                createTable(tableExists);
095            } catch (ConcurrentUpdateException e) {
096                // ignore concurrent table creation
097            }
098            return true; // load data
099        }
100    }
101
102    private void addMissingColumns() {
103        try (Statement stmt = connection.createStatement()) {
104
105            for (Column column : getMissingColumns(false)) {
106                String alter = table.getAddColumnSql(column);
107                if (logger.isLogEnabled()) {
108                    logger.log(alter);
109                }
110                stmt.execute(alter);
111            }
112        } catch (SQLException e) {
113            throw new DirectoryException(String.format("Table '%s' alteration failed: %s", table, e.getMessage()), e);
114        }
115    }
116
117    private void createTable(boolean drop) {
118        try (Statement stmt = connection.createStatement()) {
119            if (drop) {
120                // drop table
121                String dropSql = table.getDropSql();
122                if (logger.isLogEnabled()) {
123                    logger.log(dropSql);
124                }
125                stmt.execute(dropSql);
126            }
127
128            String createSql = table.getCreateSql();
129            if (logger.isLogEnabled()) {
130                logger.log(createSql);
131            }
132            stmt.execute(createSql);
133            for (String sql : table.getPostCreateSqls(null)) {
134                if (logger.isLogEnabled()) {
135                    logger.log(sql);
136                }
137                stmt.execute(sql);
138            }
139        } catch (SQLException e) {
140            if (table.getDialect().isConcurrentUpdateException(e)) {
141                log.debug(e, e);
142                log.warn(e.toString());
143                throw new ConcurrentUpdateException(e);
144            }
145            throw new DirectoryException(String.format("Table '%s' creation failed: %s", table, e.getMessage()), e);
146        }
147    }
148
149    public boolean hasMatchingColumns() {
150        if (!getMissingColumns(true).isEmpty()) {
151            return false;
152        } else {
153            // all fields have a matching column, this looks not that bad
154            log.debug("all fields matched for table {}", tableName);
155            return true;
156        }
157    }
158
159    public Set<Column> getMissingColumns(Boolean breakAtFirstMissing) {
160        try {
161            Set<Column> missingColumns = new HashSet<>();
162
163            // Test whether there are new fields added in the schema that are
164            // not present in the table schema. If so it is advised to
165            // reinitialise the database.
166
167            Set<String> columnNames = getPhysicalColumns();
168
169            // check the field names match the column names (case-insensitive)
170            for (Column column : table.getColumns()) {
171                // TODO: check types as well
172                String fieldName = column.getPhysicalName();
173                if (!columnNames.contains(fieldName)) {
174                    log.debug("required field: {} is missing", fieldName);
175                    missingColumns.add(column);
176
177                    if (breakAtFirstMissing) {
178                        return Set.of();
179                    }
180                }
181            }
182
183            return missingColumns;
184        } catch (SQLException e) {
185            log.warn("error while introspecting table: " + tableName, e);
186            return Set.of();
187        }
188    }
189
190    private Set<String> getPhysicalColumns() throws SQLException {
191        ResultSet rs = null;
192        Set<String> columnNames = new HashSet<>();
193        try {
194            // fetch the database columns definitions
195            DatabaseMetaData metadata = connection.getMetaData();
196            rs = metadata.getColumns(null, "%", tableName, "%");
197
198            while (rs.next()) {
199                columnNames.add(rs.getString("COLUMN_NAME"));
200            }
201        } finally {
202            if (rs != null) {
203                try {
204                    rs.close();
205                } catch (SQLException e) {
206                    log.warn("Error while trying to close result set", e);
207                }
208            }
209        }
210        return columnNames;
211    }
212
213    private boolean tableExists() {
214        try {
215            // Check if table exists using metadata
216            DatabaseMetaData metaData = connection.getMetaData();
217            String schemaName = null;
218            String productName = metaData.getDatabaseProductName();
219            if ("Oracle".equals(productName)) {
220                try (Statement st = connection.createStatement()) {
221                    String sql = "SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM DUAL";
222                    log.trace("SQL: {}", sql);
223                    try (ResultSet rs = st.executeQuery(sql)) {
224                        rs.next();
225                        schemaName = rs.getString(1);
226                        log.trace("checking existing tables for oracle database, schema: {}", schemaName);
227                    }
228                }
229            }
230            try (ResultSet rs = metaData.getTables(null, schemaName, table.getPhysicalName(),
231                    new String[] { "TABLE" })) {
232                boolean exists = rs.next();
233                log.debug("checking if table {} exists: {}", table.getPhysicalName(), exists);
234                return exists;
235            }
236        } catch (SQLException e) {
237            throw new DirectoryException(e);
238        }
239    }
240
241    public static Table addTable(String name, Dialect dialect, boolean nativeCase) {
242        String physicalName = dialect.getTableName(name);
243        if (!nativeCase && name.length() == physicalName.length()) {
244            // we can keep the name specified in the config
245            physicalName = name;
246        }
247        return new TableImpl(dialect, physicalName, physicalName);
248    }
249
250    public static Column addColumn(Table table, String fieldName, ColumnType type, boolean nativeCase) {
251        String physicalName = table.getDialect().getColumnName(fieldName);
252        if (!nativeCase && fieldName.length() == physicalName.length()) {
253            // we can keep the name specified in the config
254            physicalName = fieldName;
255        }
256        Column column = new Column(table, physicalName, type, fieldName);
257        return ((TableImpl) table).addColumn(fieldName, column);
258    }
259
260}