001/*
002 *  Copyright 2020 Anyware Services
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 */
016package org.ametys.core.migration.storage.impl;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.sql.Connection;
021import java.sql.PreparedStatement;
022import java.sql.ResultSet;
023import java.sql.SQLException;
024import java.sql.Timestamp;
025import java.time.Instant;
026import java.util.ArrayList;
027import java.util.List;
028import java.util.Map;
029import java.util.Optional;
030import java.util.TreeMap;
031
032import org.apache.avalon.framework.configuration.Configuration;
033import org.apache.avalon.framework.configuration.ConfigurationException;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.avalon.framework.service.Serviceable;
037import org.apache.commons.io.IOUtils;
038import org.apache.commons.lang3.StringUtils;
039import org.apache.excalibur.source.Source;
040import org.apache.excalibur.source.SourceResolver;
041
042import org.ametys.core.datasource.ConnectionHelper;
043import org.ametys.core.migration.MigrationException;
044import org.ametys.core.migration.NotMigrableInSafeModeException;
045import org.ametys.core.migration.configuration.VersionConfiguration;
046import org.ametys.core.migration.configuration.impl.SqlVersionConfiguration;
047import org.ametys.core.migration.storage.VersionStorage;
048import org.ametys.core.migration.version.Version;
049import org.ametys.core.migration.version.impl.SqlVersion;
050import org.ametys.core.script.SQLScriptHelper;
051import org.ametys.runtime.config.Config;
052import org.ametys.runtime.plugin.component.AbstractLogEnabled;
053
054/**
055 * SQL implementation of {@link VersionStorage}, to manage the list of versions in a database
056 */
057public class SqlVersionStorage extends AbstractLogEnabled implements VersionStorage, Serviceable
058{
059    private static final String __VERSION_TABLE_NAME = "Ametys_Versions";
060
061    private static final String __COLUMN_COMPONENT = "component_identifier";
062    private static final String __COLUMN_VERSION = "ametys_version_number";
063    private static final String __COLUMN_DATE = "execution_date";
064    private static final String __COLUMN_COMMENT = "execution_comment";
065    
066    private static final TreeMap<String, String> __AMETYS_VERSIONS_UDPATES = new TreeMap<>();
067    static
068    {
069        __AMETYS_VERSIONS_UDPATES.put("20210401T0000", "Modify column 'component_identifier' to enlarge it to 250 characters.");
070    }
071    
072    /** Source resolver */
073    protected SourceResolver _sourceResolver;
074    
075    @Override
076    public void service(ServiceManager manager) throws ServiceException
077    {
078        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
079    }
080    
081    @Override
082    public Version getCurrentVersion(String componentIdentifier, VersionConfiguration versionConfiguration, String versionHandlerId) throws MigrationException, NotMigrableInSafeModeException
083    {
084        getLogger().debug("getCurrentVersion for component: {}", componentIdentifier);
085        
086        return _getVersionsForComponent(componentIdentifier, versionConfiguration, versionHandlerId, true)
087                .stream()
088                .findFirst()
089                .orElse(null);
090    }
091    
092    @Override
093    public List<Version> getAllVersions(String componentIdentifier, VersionConfiguration versionConfiguration, String versionHandlerId) throws MigrationException, NotMigrableInSafeModeException
094    {
095        getLogger().debug("getAllVersions for component: {}", componentIdentifier);
096        
097        return _getVersionsForComponent(componentIdentifier, versionConfiguration, versionHandlerId, false);
098    }
099    
100    /**
101     * Get the list of versions for a component
102     * @param componentIdentifier componentIdentifier
103     * @param versionConfiguration versionConfiguration
104     * @param versionHandlerId versionHandlerId
105     * @param onlyFirstVersion Only first version is returned
106     * @return a list of current Version, or an empty list if there is a configuration problem (no version is possible)
107     * @throws MigrationException Something went wrong
108     * @throws NotMigrableInSafeModeException Impossible to find the config
109     */
110    private List<Version> _getVersionsForComponent(String componentIdentifier, Object versionConfiguration, String versionHandlerId, boolean onlyFirstVersion) throws MigrationException, NotMigrableInSafeModeException
111    {
112        if (!(versionConfiguration instanceof SqlVersionConfiguration))
113        {
114            throw new MigrationException("The version configuration object should be an instance of SqlVersionConfiguration");
115        }
116        String dataSourceId = ((SqlVersionConfiguration) versionConfiguration).getDataSourceId();
117        Connection connection = null;
118        PreparedStatement stmt = null;
119        ResultSet rs = null;
120        
121        try
122        {
123            _initVersionsTableIfNeeded(dataSourceId);
124            connection = ConnectionHelper.getConnection(dataSourceId);
125            return _getVersionsForComponent(connection, dataSourceId, componentIdentifier, versionHandlerId, onlyFirstVersion);
126        }
127        catch (SQLException e)
128        {
129            String message = String.format("Error while getting the versions for: %s (dataSource: %s)", componentIdentifier, dataSourceId);
130            throw new MigrationException(message, e);
131        }
132        finally
133        {
134            // Close connections
135            ConnectionHelper.cleanup(rs);
136            ConnectionHelper.cleanup(stmt);
137            ConnectionHelper.cleanup(connection);
138        }
139    }
140    
141    private List<Version> _getVersionsForComponent(Connection connection, String dataSourceId, String componentIdentifier, String versionHandlerId, boolean onlyFirstVersion) throws SQLException
142    {
143        PreparedStatement stmt = null;
144        ResultSet rs = null;
145        
146        try
147        {
148            String sql = "SELECT * FROM " + __VERSION_TABLE_NAME + " WHERE " + __COLUMN_COMPONENT + " = ? ORDER BY " + __COLUMN_VERSION + " DESC";
149            
150            stmt = connection.prepareStatement(sql);
151            stmt.setString(1, componentIdentifier);
152            
153            rs = stmt.executeQuery();
154            
155            List<Version> versions = new ArrayList<>();
156            
157            while (rs.next() && (!onlyFirstVersion || versions.isEmpty()))
158            {
159                String versionNumber = rs.getString(__COLUMN_VERSION);
160                Timestamp timestamp = rs.getTimestamp(__COLUMN_DATE);
161                String comment = rs.getString(__COLUMN_COMMENT);
162                
163                SqlVersion version = new SqlVersion(versionHandlerId, componentIdentifier, versionNumber, timestamp.toInstant(), comment, dataSourceId, ConnectionHelper.getDatabaseType(connection));
164                versions.add(version);
165            }
166            
167            // If only first version should be returned but the list is empty, create an empty version.
168            if (onlyFirstVersion && versions.isEmpty())
169            {
170                // No version stored in SQL
171                versions.add(new SqlVersion(versionHandlerId, componentIdentifier, null, null, null, dataSourceId, ConnectionHelper.getDatabaseType(connection)));
172            }
173            
174            return versions;
175        }
176        finally
177        {
178            ConnectionHelper.cleanup(rs);
179            ConnectionHelper.cleanup(stmt);
180        }
181    }
182
183    @Override
184    public void addVersion(Version version) throws MigrationException
185    {
186        getLogger().debug("Add version for: {}", version.toString());
187        
188        if (!(version instanceof SqlVersion))
189        {
190            throw new MigrationException("Impossible to create a SQL version in a non-sql version: " + version.toString());
191        }
192        
193        SqlVersion sqlVersion = (SqlVersion) version;
194        String dataSourceId = sqlVersion.getDatasourceId();
195    
196        _initVersionsTableIfNeeded(dataSourceId);
197
198        Connection connection = null;
199        
200        try
201        {
202            connection = ConnectionHelper.getConnection(dataSourceId);
203            _addVersion(connection, sqlVersion);
204        }
205        catch (SQLException e)
206        {
207            throw new MigrationException("Error during SQL query to set the new version for: " + version.toString() + " (dataSource: " + dataSourceId + ")", e);
208        }
209        finally
210        {
211            // Close connections
212            ConnectionHelper.cleanup(connection);
213        }
214
215        getLogger().debug("End add version for: {}", version.toString());
216    }
217    
218    private void _addVersion(Connection connection, Version version) throws SQLException
219    {
220        PreparedStatement stmt = null;
221        
222        try
223        {
224            String sql = "INSERT INTO " + __VERSION_TABLE_NAME + " (" + __COLUMN_COMPONENT + ", " + __COLUMN_VERSION + ", " + __COLUMN_DATE + ", " + __COLUMN_COMMENT + ") VALUES (?, ?, ?, ?)";
225            
226            stmt = connection.prepareStatement(sql);
227            stmt.setString(1, version.getComponentId());
228            stmt.setString(2, version.getVersionNumber());
229            stmt.setTimestamp(3, Timestamp.from(version.getExecutionInstant()));
230            stmt.setString(4, version.getComment());
231            
232            stmt.executeUpdate();
233        }
234        finally
235        {
236            // Close connections
237            ConnectionHelper.cleanup(stmt);
238        }
239    }
240
241    @Override
242    public void removeAllVersions(String componentIdentifier, VersionConfiguration versionConfiguration) throws MigrationException
243    {
244        getLogger().debug("Start remove all version for component: {}", componentIdentifier);
245
246        if (!(versionConfiguration instanceof SqlVersionConfiguration))
247        {
248            throw new MigrationException("The version configuration object should be an instance of SqlVersionConfiguration");
249        }
250        String dataSourceId = ((SqlVersionConfiguration) versionConfiguration).getDataSourceId();
251        Connection connection = null;
252        PreparedStatement stmt = null;
253        
254        try
255        {
256            _initVersionsTableIfNeeded(dataSourceId);
257            
258            connection = ConnectionHelper.getConnection(dataSourceId);
259            String sql = "DELETE FROM " + __VERSION_TABLE_NAME + " WHERE " + __COLUMN_COMPONENT + " = ?";
260            
261            stmt = connection.prepareStatement(sql);
262            stmt.setString(1, componentIdentifier);
263            
264            stmt.executeUpdate();
265        }
266        catch (SQLException e)
267        {
268            String message = String.format("Error while getting the versions for %s (datasource: %s)", componentIdentifier, dataSourceId);
269            throw new MigrationException(message, e);
270        }
271        finally
272        {
273            // Close connections
274            ConnectionHelper.cleanup(stmt);
275            ConnectionHelper.cleanup(connection);
276        }
277        
278        getLogger().debug("End remove all version for component: {}", componentIdentifier);
279    }
280    
281    /**
282     * Create the versions table if needed
283     * @param dataSourceId the datasource where the table will be created
284     * @throws MigrationException Something went wrong
285     */
286    protected void _initVersionsTableIfNeeded(String dataSourceId) throws MigrationException
287    {
288        getLogger().debug("Start initVersionsTableIfNeeded");
289        
290        Connection connection = null;
291        try
292        {
293            String componentId = "ametys.versions.table";
294            String maxVersionNumber = __AMETYS_VERSIONS_UDPATES.lastKey();
295            
296            connection = ConnectionHelper.getConnection(dataSourceId);
297            
298            String databaseType = ConnectionHelper.getDatabaseType(connection);
299            
300            // Test and create tables
301            if (SQLScriptHelper.createTableIfNotExists(connection, __VERSION_TABLE_NAME, "plugin:core://scripts/%s/ametys_versions.sql", _sourceResolver))
302            {
303                // Add the higher version
304                SqlVersion version = new SqlVersion("sql", componentId, maxVersionNumber, Instant.now(), "Automatic Initialization.", dataSourceId, databaseType);
305                _addVersion(connection, version);
306            }
307            else
308            {
309                // Get the current version
310                String currentVersion = _getVersionsForComponent(connection, dataSourceId, componentId, "sql", true)
311                        .stream()
312                        .findFirst()
313                        .map(Version::getVersionNumber)
314                        // If no version, all upgrades should be applied
315                        .orElse("0");
316                
317                // Apply missing upgrades
318                String nextVersion = __AMETYS_VERSIONS_UDPATES.higherKey(currentVersion);
319                if (nextVersion != null)
320                {
321                    for (Map.Entry<String, String> versionEntry : __AMETYS_VERSIONS_UDPATES.tailMap(nextVersion).entrySet())
322                    {
323                        String versionNumber = versionEntry.getKey();
324                        String comment = versionEntry.getValue();
325                        getLogger().info("Upgrade table '{}' to version '{}' for component '{}' on datasource '{}'{}", __VERSION_TABLE_NAME, versionNumber, componentId, dataSourceId, StringUtils.isNotBlank(comment) ? " (" + comment + ")" : StringUtils.EMPTY);
326                        
327                        Source source = null;
328                        try
329                        {
330                            source = _sourceResolver.resolveURI(String.format("plugin:core://scripts/%s/ametys_versions/v%s.sql", databaseType, versionNumber));
331                            try (InputStream is = source.getInputStream())
332                            {
333                                String script = IOUtils.toString(is, "UTF-8");
334                                SQLScriptHelper.runScript(connection, script);
335
336                                comment = Optional.ofNullable(comment)
337                                        .filter(StringUtils::isNotBlank)
338                                        .map(c -> "Automatic Upgrade: " + c)
339                                        .orElse("Automatic Upgrade.");
340                                
341                                SqlVersion version = new SqlVersion("sql", componentId, versionNumber, Instant.now(), comment, dataSourceId, databaseType);
342                                _addVersion(connection, version);
343                            }
344                        }
345                        finally
346                        {
347                            _sourceResolver.release(source);
348                        }
349                    }
350                }
351                else
352                {
353                    getLogger().debug("The table {} of datasource '{}' is already up to date", __VERSION_TABLE_NAME, dataSourceId);
354                }
355            }
356        }
357        catch (SQLException | IOException e)
358        {
359            String errorMsg = String.format("Error during SQL tables initialization for data source id: '%s'.", StringUtils.defaultString(dataSourceId));
360            throw new MigrationException(errorMsg, e);
361        }
362        finally
363        {
364            ConnectionHelper.cleanup(connection);
365        }
366        
367        getLogger().debug("End initVersionsTableIfNeeded");
368    }
369    
370    public VersionConfiguration getConfiguration(String componentId, Configuration versionConfiguration) throws ConfigurationException, NotMigrableInSafeModeException
371    {
372        String dataSourceId = null;
373        Configuration dataSourceConf = versionConfiguration.getChild("datasource");
374        String dataSourceConfParam = dataSourceConf.getValue(null);
375        if (StringUtils.isBlank(dataSourceConfParam))
376        {
377            throw new ConfigurationException("The 'datasource' configuration node must be defined and contain non empty value.", dataSourceConf);
378        }
379        
380        String dataSourceConfType = dataSourceConf.getAttribute("type", null);
381        
382        if (StringUtils.equals(dataSourceConfType, "config"))
383        {
384            if (Config.getInstance() != null)
385            {
386                dataSourceId = Config.getInstance().getValue(dataSourceConfParam);
387            }
388            else
389            {
390                throw new NotMigrableInSafeModeException("Config is needed to migrate " + componentId);
391            }
392        }
393        else // expecting type="id"
394        {
395            dataSourceId = dataSourceConfParam;
396        }
397        
398        return new SqlVersionConfiguration(dataSourceId);
399    }
400}