mirror of
https://github.com/ebean-orm/ebean.git
synced 2026-10-24 10:38:57 +00:00
#636 - ENH: Add DB Migration runner (as alternative to running via FlywayDB etc)
This commit is contained in:
@@ -64,7 +64,7 @@
|
||||
<dependency>
|
||||
<groupId>org.avaje</groupId>
|
||||
<artifactId>avaje-datasource</artifactId>
|
||||
<version>1.1.1</version>
|
||||
<version>1.1.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- bring in cluster support by default -->
|
||||
@@ -92,12 +92,6 @@
|
||||
<version>[1.7.1,1.7.99)</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.avaje</groupId>
|
||||
<artifactId>avaje-classpath-scanner</artifactId>
|
||||
<version>1.1.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- provided scope for Spring boot loader classpath scanning support -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
@@ -74,6 +74,9 @@ public class ClassLoadConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the resources for the given name.
|
||||
*/
|
||||
public Enumeration<URL> getResources(String name) throws IOException {
|
||||
return context.getResources(name);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import com.avaje.ebean.dbmigration.DbMigration;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Configuration for the DB migration processing.
|
||||
*/
|
||||
@@ -67,6 +69,36 @@ public class DbMigrationConfig {
|
||||
*/
|
||||
protected String generatePendingDrop;
|
||||
|
||||
/**
|
||||
* For running migration the DB table that holds migration execution status.
|
||||
*/
|
||||
protected String metaTable = "db_migration";
|
||||
|
||||
/**
|
||||
* Flag set to true means to run any outstanding migrations on startup.
|
||||
*/
|
||||
protected boolean runMigration;
|
||||
|
||||
/**
|
||||
* Comma and equals delimited key/value placeholders to replace in DDL scripts.
|
||||
*/
|
||||
protected String runPlaceholders;
|
||||
|
||||
/**
|
||||
* Map of key/value placeholders to replace in DDL scripts.
|
||||
*/
|
||||
protected Map<String,String> runPlaceholderMap;
|
||||
|
||||
/**
|
||||
* DB user used to run the DB migration.
|
||||
*/
|
||||
protected String dbUser;
|
||||
|
||||
/**
|
||||
* DB password used to run the DB migration.
|
||||
*/
|
||||
protected String dbPassword;
|
||||
|
||||
/**
|
||||
* Return the DB platform to generate migration DDL for.
|
||||
*
|
||||
@@ -199,10 +231,119 @@ public class DbMigrationConfig {
|
||||
this.modelPath = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the table name that holds the migration run details
|
||||
* (used by DB Migration runner only).
|
||||
*/
|
||||
public String getMetaTable() {
|
||||
return metaTable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the table name that holds the migration run details
|
||||
* (used by DB Migration runner only).
|
||||
*/
|
||||
public void setMetaTable(String metaTable) {
|
||||
this.metaTable = metaTable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a comma and equals delimited placeholders that are substituted in SQL scripts when running migration
|
||||
* (used by DB Migration runner only).
|
||||
*/
|
||||
public String getRunPlaceholders() {
|
||||
// environment properties take precedence
|
||||
String placeholders = readEnvironment("ddl.migration.placeholders");
|
||||
if (placeholders != null) {
|
||||
return placeholders;
|
||||
}
|
||||
return runPlaceholders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a comma and equals delimited placeholders that are substituted in SQL scripts when running migration
|
||||
* (used by DB Migration runner only).
|
||||
*/
|
||||
public void setRunPlaceholders(String runPlaceholders) {
|
||||
this.runPlaceholders = runPlaceholders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a map of placeholder values that are substituted in SQL scripts when running migration
|
||||
* (used by DB Migration runner only).
|
||||
*/
|
||||
public Map<String, String> getRunPlaceholderMap() {
|
||||
return runPlaceholderMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a map of placeholder values that are substituted when running migration
|
||||
* (used by DB Migration runner only).
|
||||
*/
|
||||
public void setRunPlaceholderMap(Map<String, String> runPlaceholderMap) {
|
||||
this.runPlaceholderMap = runPlaceholderMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the DB migration should be run on startup.
|
||||
*/
|
||||
public boolean isRunMigration() {
|
||||
// environment properties take precedence
|
||||
String run = readEnvironment("ddl.migration.run");
|
||||
if (run != null) {
|
||||
return "true".equalsIgnoreCase(run.trim());
|
||||
}
|
||||
return runMigration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set to true to run the DB migration on startup.
|
||||
*/
|
||||
public void setRunMigration(boolean runMigration) {
|
||||
this.runMigration = runMigration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the DB user to use for running DB migrations.
|
||||
*/
|
||||
public String getDbUser() {
|
||||
// environment properties take precedence
|
||||
String user = readEnvironment("ddl.migration.user");
|
||||
if (user != null) {
|
||||
return user;
|
||||
}
|
||||
return dbUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the DB user to use for running DB migrations.
|
||||
*/
|
||||
public void setDbUser(String dbUser) {
|
||||
this.dbUser = dbUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the DB password to use for running DB migrations.
|
||||
*/
|
||||
public String getDbPassword() {
|
||||
String user = readEnvironment("ddl.migration.password");
|
||||
if (user != null) {
|
||||
return user;
|
||||
}
|
||||
return dbPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the DB password to use for running DB migrations.
|
||||
*/
|
||||
public void setDbPassword(String dbPassword) {
|
||||
this.dbPassword = dbPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the settings from the PropertiesWrapper.
|
||||
*/
|
||||
public void loadSettings(PropertiesWrapper properties) {
|
||||
public void loadSettings(PropertiesWrapper properties, String serverName) {
|
||||
|
||||
migrationPath = properties.get("migration.migrationPath", migrationPath);
|
||||
if (properties.getBoolean("migration.singleDirectory", false)) {
|
||||
@@ -219,7 +360,19 @@ public class DbMigrationConfig {
|
||||
|
||||
generate = properties.getBoolean("migration.generate", generate);
|
||||
version = properties.get("migration.version", version);
|
||||
name = properties.get("migration.name", name);
|
||||
this.name = properties.get("migration.name", this.name);
|
||||
|
||||
runMigration = properties.getBoolean("migration.run", runMigration);
|
||||
metaTable = properties.get("migration.metaTable", metaTable);
|
||||
runPlaceholders = properties.get("migration.placeholders", runPlaceholders);
|
||||
|
||||
String adminUser = properties.get("datasource."+serverName+".user", dbUser);
|
||||
adminUser = properties.get("datasource."+serverName+".adminuser", adminUser);
|
||||
dbUser = properties.get("migration.dbuser", adminUser);
|
||||
|
||||
String adminPwd = properties.get("datasource."+serverName+".password", dbPassword);
|
||||
adminPwd = properties.get("datasource."+serverName+".adminpassword", adminPwd);
|
||||
dbPassword = properties.get("migration.dbpassword", adminPwd);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2267,7 +2267,7 @@ public class ServerConfig {
|
||||
*/
|
||||
protected void loadSettings(PropertiesWrapper p) {
|
||||
|
||||
migrationConfig.loadSettings(p);
|
||||
migrationConfig.loadSettings(p, name);
|
||||
|
||||
namingConvention = createNamingConvention(p, namingConvention);
|
||||
if (namingConvention != null) {
|
||||
|
||||
@@ -540,6 +540,9 @@ public class DatabasePlatform {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the resultSet.
|
||||
*/
|
||||
protected void close(ResultSet resultSet) {
|
||||
try {
|
||||
resultSet.close();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.avaje.ebean.dbmigration;
|
||||
|
||||
import com.avaje.ebean.EbeanServer;
|
||||
import com.avaje.ebean.Transaction;
|
||||
import com.avaje.ebean.config.DbMigrationConfig;
|
||||
import com.avaje.ebean.config.ServerConfig;
|
||||
import com.avaje.ebean.dbmigration.runner.LocalMigrationResource;
|
||||
@@ -11,62 +10,91 @@ import com.avaje.ebeaninternal.util.JdbcClose;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.io.IOException;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
*
|
||||
* Runs the DB migration typically on application start.
|
||||
*/
|
||||
public class MigrationRunner {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MigrationRunner.class);
|
||||
|
||||
final EbeanServer server;
|
||||
final ServerConfig config;
|
||||
private final EbeanServer server;
|
||||
|
||||
private final ServerConfig config;
|
||||
|
||||
private final DbMigrationConfig migrationConfig;
|
||||
|
||||
public MigrationRunner(EbeanServer server) {
|
||||
public MigrationRunner(EbeanServer server, DbMigrationConfig migrationConfig) {
|
||||
this.server = server;
|
||||
this.config = server.getPluginApi().getServerConfig();
|
||||
migrationConfig = this.config.getMigrationConfig();
|
||||
this.migrationConfig = migrationConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the migrations if there are any that need running.
|
||||
*/
|
||||
public void run() {
|
||||
|
||||
LocalMigrationResources resources = new LocalMigrationResources(config);
|
||||
LocalMigrationResources resources = new LocalMigrationResources(config, migrationConfig);
|
||||
if (!resources.readResources()) {
|
||||
logger.debug("no migrations to check");
|
||||
return;
|
||||
}
|
||||
|
||||
Transaction transaction = server.createTransaction();
|
||||
Connection connection = transaction.getConnection();
|
||||
String migrationUser = migrationConfig.getDbUser();
|
||||
String migrationPwd = migrationConfig.getDbPassword();
|
||||
|
||||
DataSource dataSource = server.getPluginApi().getDataSource();
|
||||
|
||||
Connection connection;
|
||||
try {
|
||||
MigrationTable table = new MigrationTable(server, migrationConfig, connection);
|
||||
table.createIfNeeded();
|
||||
connection = dataSource.getConnection(migrationUser, migrationPwd);
|
||||
} catch (SQLException e) {
|
||||
throw new IllegalArgumentException("Error trying to connect to database using DB Migration user [" + migrationUser + "]", e);
|
||||
}
|
||||
|
||||
LocalMigrationResource priorVersion = null;
|
||||
List<LocalMigrationResource> localVersions = resources.getVersions();
|
||||
for (int i = 0; i < localVersions.size(); i++) {
|
||||
LocalMigrationResource localVersion = localVersions.get(i);
|
||||
if (i > 0) {
|
||||
priorVersion = localVersions.get(i-1);
|
||||
}
|
||||
if (!table.shouldRun(localVersion, priorVersion)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
try {
|
||||
connection.setAutoCommit(false);
|
||||
|
||||
table.commit();
|
||||
runMigrations(resources, connection);
|
||||
|
||||
connection.commit();
|
||||
|
||||
} catch (Exception e) {
|
||||
JdbcClose.rollback(connection);
|
||||
throw new RuntimeException(e);
|
||||
|
||||
} finally {
|
||||
JdbcClose.close(connection);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all the migrations as needed.
|
||||
*/
|
||||
private void runMigrations(LocalMigrationResources resources, Connection connection) throws SQLException, IOException {
|
||||
|
||||
MigrationTable table = new MigrationTable(server, migrationConfig, connection);
|
||||
table.createIfNeeded();
|
||||
|
||||
// get the migrations in version order
|
||||
List<LocalMigrationResource> localVersions = resources.getVersions();
|
||||
|
||||
LocalMigrationResource priorVersion = null;
|
||||
|
||||
// run migrations in order
|
||||
for (int i = 0; i < localVersions.size(); i++) {
|
||||
LocalMigrationResource localVersion = localVersions.get(i);
|
||||
if (!table.shouldRun(localVersion, priorVersion)) {
|
||||
break;
|
||||
}
|
||||
priorVersion = localVersion;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.avaje.ebean.dbmigration.model.MigrationVersion;
|
||||
import org.avaje.classpath.scanner.Resource;
|
||||
|
||||
/**
|
||||
* A DB migration resource.
|
||||
* A DB migration resource (DDL script with version).
|
||||
*/
|
||||
public class LocalMigrationResource implements Comparable<LocalMigrationResource> {
|
||||
|
||||
@@ -14,6 +14,9 @@ public class LocalMigrationResource implements Comparable<LocalMigrationResource
|
||||
|
||||
private final Resource resource;
|
||||
|
||||
/**
|
||||
* Construct with version and resource.
|
||||
*/
|
||||
public LocalMigrationResource(MigrationVersion version, String location, Resource resource) {
|
||||
this.version = version;
|
||||
this.location = location;
|
||||
|
||||
@@ -3,9 +3,8 @@ package com.avaje.ebean.dbmigration.runner;
|
||||
import com.avaje.ebean.config.DbMigrationConfig;
|
||||
import com.avaje.ebean.config.ServerConfig;
|
||||
import com.avaje.ebean.dbmigration.model.MigrationVersion;
|
||||
import org.avaje.classpath.scanner.Location;
|
||||
import org.avaje.classpath.scanner.MatchResource;
|
||||
import org.avaje.classpath.scanner.Resource;
|
||||
import org.avaje.classpath.scanner.ResourceFilter;
|
||||
import org.avaje.classpath.scanner.Scanner;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -15,7 +14,7 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
*
|
||||
* Loads the DB migration resources and sorts them into execution order.
|
||||
*/
|
||||
public class LocalMigrationResources {
|
||||
|
||||
@@ -27,11 +26,17 @@ public class LocalMigrationResources {
|
||||
|
||||
private final List<LocalMigrationResource> versions = new ArrayList<LocalMigrationResource>();
|
||||
|
||||
public LocalMigrationResources(ServerConfig serverConfig) {
|
||||
/**
|
||||
* Construct with configuration options.
|
||||
*/
|
||||
public LocalMigrationResources(ServerConfig serverConfig, DbMigrationConfig migrationConfig) {
|
||||
this.serverConfig = serverConfig;
|
||||
this.migrationConfig = serverConfig.getMigrationConfig();
|
||||
this.migrationConfig = migrationConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all the migration resources (SQL scripts) returning true if there are versions.
|
||||
*/
|
||||
public boolean readResources() {
|
||||
|
||||
String migrationPath = migrationConfig.getMigrationPath();
|
||||
@@ -59,14 +64,20 @@ public class LocalMigrationResources {
|
||||
return !versions.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of migration resources in version order.
|
||||
*/
|
||||
public List<LocalMigrationResource> getVersions() {
|
||||
return versions;
|
||||
}
|
||||
|
||||
|
||||
static class Match implements MatchResource {
|
||||
/**
|
||||
* Filter used to find the migration scripts.
|
||||
*/
|
||||
private static class Match implements ResourceFilter {
|
||||
|
||||
final DbMigrationConfig migrationConfig;
|
||||
private final DbMigrationConfig migrationConfig;
|
||||
|
||||
Match(DbMigrationConfig migrationConfig) {
|
||||
this.migrationConfig = migrationConfig;
|
||||
@@ -74,12 +85,7 @@ public class LocalMigrationResources {
|
||||
|
||||
@Override
|
||||
public boolean isMatch(String name) {
|
||||
|
||||
return name.endsWith(migrationConfig.getApplySuffix())
|
||||
|| name.endsWith(migrationConfig.getModelSuffix())
|
||||
|| name.endsWith(migrationConfig.getDropSuffix())
|
||||
|| name.endsWith(migrationConfig.getRollbackSuffix());
|
||||
|
||||
return name.endsWith(migrationConfig.getApplySuffix());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,100 @@
|
||||
package com.avaje.ebean.dbmigration.runner;
|
||||
|
||||
import com.avaje.ebean.SqlRow;
|
||||
import com.avaje.ebean.SqlUpdate;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
|
||||
/**
|
||||
*
|
||||
* Bean holding migration execution details stored in the migration table.
|
||||
*/
|
||||
public class MigrationMetaRow implements Comparable<MigrationMetaRow> {
|
||||
class MigrationMetaRow {
|
||||
|
||||
int id;
|
||||
String status;
|
||||
String runVersion;
|
||||
String depVersion;
|
||||
String comment;
|
||||
int checksum;
|
||||
Timestamp runOn;
|
||||
String runBy;
|
||||
private int id;
|
||||
|
||||
@Override
|
||||
public int compareTo(MigrationMetaRow o) {
|
||||
return (id < o.id) ? -1 : ((id == o.id) ? 0 : 1);
|
||||
private String status;
|
||||
|
||||
private String runVersion;
|
||||
|
||||
private String depVersion;
|
||||
|
||||
private String comment;
|
||||
|
||||
private int checksum;
|
||||
|
||||
private Timestamp runOn;
|
||||
|
||||
private String runBy;
|
||||
|
||||
/**
|
||||
* Construct for inserting into table.
|
||||
*/
|
||||
MigrationMetaRow(int id, String runVersion, String priorVersion, String comment, int checksum, String runBy) {
|
||||
this.id = id;
|
||||
this.runVersion = runVersion;
|
||||
this.depVersion = priorVersion;
|
||||
this.checksum = checksum;
|
||||
this.comment = comment;
|
||||
this.runBy = runBy;
|
||||
this.runOn = new Timestamp(System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct from the SqlRow (read from table).
|
||||
*/
|
||||
MigrationMetaRow(SqlRow row) {
|
||||
id = row.getInteger("id");
|
||||
status = row.getString("status");
|
||||
runVersion = row.getString("row_version");
|
||||
depVersion = row.getString("dep_version");
|
||||
comment = row.getString("comment");
|
||||
checksum = row.getInteger("checksum");
|
||||
runOn = row.getTimestamp("run_on");
|
||||
runBy = row.getString("run_by");
|
||||
}
|
||||
|
||||
//String sql = "insert into ebean_migration (id,status,run_version,dep_version,comment,checksum,run_on,run_by,run_ip) "+
|
||||
// "values (?,?,?,?,?,?,?,?,?)";
|
||||
/**
|
||||
* Return the id for this migration.
|
||||
*/
|
||||
int getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the normalised version for this migration.
|
||||
*/
|
||||
String getRunVersion() {
|
||||
return runVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the checksum for this migration.
|
||||
*/
|
||||
int getChecksum() {
|
||||
return checksum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind to the insert statement.
|
||||
*/
|
||||
void bindInsert(SqlUpdate insert) {
|
||||
insert.setParameter(1, id);
|
||||
insert.setParameter(2, "success");
|
||||
insert.setParameter(3, runVersion);
|
||||
insert.setParameter(4, depVersion);
|
||||
insert.setParameter(5, comment);
|
||||
insert.setParameter(6, checksum);
|
||||
insert.setParameter(7, runOn);
|
||||
insert.setParameter(8, runBy);
|
||||
insert.setParameter(9, "ip");
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the SQL insert given the table migration meta data is stored in.
|
||||
*/
|
||||
static String insertSql(String table) {
|
||||
return "insert into " + table
|
||||
+ " (id, status, run_version, dep_version, comment, checksum, run_on, run_by, run_ip)"
|
||||
+ " values (?,?,?,?,?,?,?,?,?)";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@ import com.avaje.ebean.dbmigration.DdlRunner;
|
||||
import java.sql.Connection;
|
||||
|
||||
/**
|
||||
* Created by rob on 5/02/16.
|
||||
* Runs the DDL migration scripts.
|
||||
*/
|
||||
public class MigrationScriptRunner {
|
||||
|
||||
final Connection connection;
|
||||
private final Connection connection;
|
||||
|
||||
/**
|
||||
* Construct with a given connection.
|
||||
*/
|
||||
public MigrationScriptRunner(Connection connection) {
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@ import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.Enumeration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Created by rob on 5/02/16.
|
||||
* Manages the migration table.
|
||||
*/
|
||||
public class MigrationTable {
|
||||
|
||||
@@ -34,34 +35,53 @@ public class MigrationTable {
|
||||
|
||||
private final DatabasePlatform databasePlatform;
|
||||
|
||||
private final DbMigrationConfig migrationConfig;
|
||||
|
||||
private final String catalog;
|
||||
private final String schema;
|
||||
private final String table;
|
||||
private final ServerConfig serverConfig;
|
||||
private final String envUserName;
|
||||
|
||||
int currentSearchPosition;
|
||||
private final ScriptTransform scriptTransform;
|
||||
|
||||
List<SqlRow> metaRows;
|
||||
private final String insertSql;
|
||||
|
||||
private final LinkedHashMap<String,MigrationMetaRow> migrations;
|
||||
|
||||
private MigrationMetaRow lastMigration;
|
||||
|
||||
/**
|
||||
* Construct with server, configuration and jdbc connection (DB admin user).
|
||||
*/
|
||||
public MigrationTable(EbeanServer server, DbMigrationConfig migrationConfig, Connection connection) {
|
||||
this.connection = connection;
|
||||
this.server = server;
|
||||
this.migrations = new LinkedHashMap<String, MigrationMetaRow>();
|
||||
|
||||
SpiServer pluginApi = server.getPluginApi();
|
||||
this.serverConfig = pluginApi.getServerConfig();
|
||||
this.databasePlatform = pluginApi.getDatabasePlatform();
|
||||
this.migrationConfig = migrationConfig;
|
||||
|
||||
this.catalog = null;
|
||||
this.schema = null;
|
||||
this.table = "ebean_migration";
|
||||
this.table = migrationConfig.getMetaTable();
|
||||
this.insertSql = MigrationMetaRow.insertSql(table);
|
||||
|
||||
this.scriptTransform = createScriptTransform(migrationConfig);
|
||||
this.envUserName = System.getProperty("user.name");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the ScriptTransform for placeholder key/value replacement.
|
||||
*/
|
||||
private ScriptTransform createScriptTransform(DbMigrationConfig config) {
|
||||
|
||||
Map<String, String> map = PlaceholderBuilder.build(config.getRunPlaceholders(), config.getRunPlaceholderMap());
|
||||
return new ScriptTransform(map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the table is it does not exist.
|
||||
*/
|
||||
public void createIfNeeded() throws SQLException, IOException {
|
||||
|
||||
if (!tableExists(connection)) {
|
||||
@@ -69,20 +89,39 @@ public class MigrationTable {
|
||||
}
|
||||
|
||||
ExternalJdbcTransaction t = new ExternalJdbcTransaction(connection);
|
||||
SqlQuery sqlQuery = server.createSqlQuery("select * from ebean_migration order by id for update");
|
||||
metaRows = server.findList(sqlQuery, t);
|
||||
SqlQuery sqlQuery = server.createSqlQuery("select * from "+table+" order by id for update");
|
||||
List<SqlRow> metaRows = server.findList(sqlQuery, t);
|
||||
|
||||
for (SqlRow row : metaRows) {
|
||||
addMigration(new MigrationMetaRow(row));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void createTable(Connection connection) throws IOException {
|
||||
|
||||
String script = getCreateTableScript();
|
||||
String script = ScriptTransform.table(table, getCreateTableScript());
|
||||
|
||||
MigrationScriptRunner run = new MigrationScriptRunner(connection);
|
||||
run.runScript(false, script, "create migration tables");
|
||||
run.runScript(false, script, "create migration table");
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the create table script.
|
||||
*/
|
||||
private String getCreateTableScript() throws IOException {
|
||||
// supply a script to override the default table create script
|
||||
String script = readResource("migration-support/create-table.sql");
|
||||
if (script == null) {
|
||||
// no, just use the default script
|
||||
script = readResource("migration-support/default-create-table.sql");
|
||||
}
|
||||
return script;
|
||||
}
|
||||
|
||||
Enumeration<URL> resources = serverConfig.getClassLoadConfig().getResources("migration-support/create.sql");
|
||||
private String readResource(String location) throws IOException {
|
||||
|
||||
Enumeration<URL> resources = serverConfig.getClassLoadConfig().getResources(location);
|
||||
if (resources.hasMoreElements()) {
|
||||
URL url = resources.nextElement();
|
||||
return IOUtils.readUtf8(url.openStream());
|
||||
@@ -90,75 +129,106 @@ public class MigrationTable {
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean tableExists(Connection connection) throws SQLException {
|
||||
|
||||
/**
|
||||
* Return true if the table exists.
|
||||
*/
|
||||
private boolean tableExists(Connection connection) throws SQLException {
|
||||
return databasePlatform.tableExists(connection, catalog, schema, table);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return true if the migration ran successfully and false if the migration failed.
|
||||
*/
|
||||
public boolean shouldRun(LocalMigrationResource localVersion, LocalMigrationResource priorVersion) {
|
||||
|
||||
// if prior != null check previous version installed
|
||||
// if previous version not installed ... error - missing version (prior version)
|
||||
|
||||
// if (!priorVersionNotInstalled(priorVersion)) {
|
||||
//
|
||||
// }
|
||||
//
|
||||
// // if localVersion installed
|
||||
// // check checksum and return ok, or re-installable?
|
||||
// // else
|
||||
// // install version and continue
|
||||
//
|
||||
// if (runPosition >= metaRows.size()) {
|
||||
// logger.debug("No matching row");
|
||||
// runMigration(runPosition, localVersion);
|
||||
// return true;
|
||||
// }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void checkInstalled(LocalMigrationResource priorVersion) {
|
||||
if (priorVersion != null) {
|
||||
searchFor(priorVersion);
|
||||
// check priorVersion is installed
|
||||
MigrationMetaRow existing = migrations.get(priorVersion.getVersion().normalised());
|
||||
if (existing == null) {
|
||||
logger.warn("Migration {} requires prior migration {} which has not been run", localVersion.getVersion(), priorVersion.getVersion());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
MigrationMetaRow existing = migrations.get(localVersion.getVersion().normalised());
|
||||
if (existing == null) {
|
||||
runMigration(localVersion, priorVersion);
|
||||
return true;
|
||||
|
||||
} else {
|
||||
// check checksum and return ok, or re-run if repeatable script?
|
||||
existing.getChecksum();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void searchFor(LocalMigrationResource priorVersion) {
|
||||
|
||||
}
|
||||
|
||||
public void commit() throws SQLException {
|
||||
connection.commit();
|
||||
}
|
||||
|
||||
private void runMigration(int runPosition, LocalMigrationResource localVersion) {
|
||||
/**
|
||||
* Run the migration script.
|
||||
*/
|
||||
private void runMigration(LocalMigrationResource localVersion, LocalMigrationResource prior) {
|
||||
|
||||
logger.debug("run migration "+localVersion.getLocation());
|
||||
|
||||
|
||||
String script = localVersion.getContent();
|
||||
String script = convertScript(localVersion.getContent());
|
||||
|
||||
MigrationScriptRunner run = new MigrationScriptRunner(connection);
|
||||
run.runScript(false, script, "run migration version: "+localVersion.getVersion());
|
||||
|
||||
String sql = "insert into ebean_migration (id,status,run_version,dep_version,comment,checksum,run_on,run_by,run_ip) "+
|
||||
"values (?,?,?,?,?,?,?,?,?)";
|
||||
|
||||
SqlUpdate sqlUpdate = server.createSqlUpdate(sql);
|
||||
sqlUpdate.setParameter(1, runPosition+1);
|
||||
sqlUpdate.setParameter(2, "success");
|
||||
sqlUpdate.setParameter(3, localVersion.getVersion().getFull());
|
||||
sqlUpdate.setParameter(4, "na");
|
||||
sqlUpdate.setParameter(5, "comm");
|
||||
sqlUpdate.setParameter(6, 0);
|
||||
sqlUpdate.setParameter(7, new Timestamp(System.currentTimeMillis()));
|
||||
sqlUpdate.setParameter(8, envUserName);
|
||||
sqlUpdate.setParameter(9, "userIp");
|
||||
int checksum = Checksum.calculate(script);
|
||||
MigrationMetaRow metaRow = createMetaRow(localVersion, prior, checksum);
|
||||
|
||||
ExternalJdbcTransaction t = new ExternalJdbcTransaction(connection);
|
||||
server.execute(sqlUpdate, t);
|
||||
SqlUpdate insert = server.createSqlUpdate(insertSql);
|
||||
metaRow.bindInsert(insert);
|
||||
server.execute(insert, new ExternalJdbcTransaction(connection));
|
||||
|
||||
addMigration(metaRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the MigrationMetaRow for this migration.
|
||||
*/
|
||||
private MigrationMetaRow createMetaRow(LocalMigrationResource localVersion, LocalMigrationResource prior, int checksum) {
|
||||
|
||||
int nextId = 1;
|
||||
if (lastMigration != null) {
|
||||
nextId = lastMigration.getId() + 1;
|
||||
}
|
||||
|
||||
String runVersion = localVersion.getVersion().normalised();
|
||||
String comment = getMigrationComment(localVersion);
|
||||
String priorVersion = getMigrationPriorVersion(prior);
|
||||
|
||||
return new MigrationMetaRow(nextId, runVersion, priorVersion, comment, checksum, envUserName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the prior migration normalised version.
|
||||
*/
|
||||
private String getMigrationPriorVersion(LocalMigrationResource prior) {
|
||||
return prior != null ? prior.getVersion().normalised() : "-";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the migration comment.
|
||||
*/
|
||||
private String getMigrationComment(LocalMigrationResource localVersion) {
|
||||
String comment = localVersion.getVersion().getComment();
|
||||
return comment == null || comment.isEmpty() ? "-" : comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the placeholder key/value replacement on the script.
|
||||
*/
|
||||
private String convertScript(String script) {
|
||||
return scriptTransform.transform(script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the successfully executed migration (to allow dependant scripts to run).
|
||||
*/
|
||||
private void addMigration(MigrationMetaRow metaRow) {
|
||||
lastMigration = metaRow;
|
||||
migrations.put(metaRow.getRunVersion(), metaRow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,53 @@
|
||||
package com.avaje.ebean.dbmigration.runner;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Created by rob on 1/04/16.
|
||||
* Joins placeholder map and comma/equals delimited string.
|
||||
*/
|
||||
public class PlaceholderBuilder {
|
||||
class PlaceholderBuilder {
|
||||
|
||||
private final Map<String,String> map = new HashMap<String,String>();
|
||||
|
||||
/**
|
||||
* Create with raw comma and equals delimited pairs plus map of key value pairs.
|
||||
*/
|
||||
public static Map<String,String> build(String commaDelimited, Map<String,String> placeholders) {
|
||||
|
||||
PlaceholderBuilder builder = new PlaceholderBuilder();
|
||||
builder.add(commaDelimited);
|
||||
builder.add(placeholders);
|
||||
|
||||
return builder.map;
|
||||
}
|
||||
|
||||
private PlaceholderBuilder() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a comma and equals delimited string to parse for key value pairs.
|
||||
*/
|
||||
public void add(String commaDelimited) {
|
||||
|
||||
if (commaDelimited != null) {
|
||||
String[] split = commaDelimited.split("[,;]");
|
||||
for (String keyValue : split) {
|
||||
String[] pair = keyValue.split("=");
|
||||
if (pair.length == 2) {
|
||||
map.put(pair[0].trim(), pair[1].trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a map of key value placeholder pairs.
|
||||
*/
|
||||
public void add(Map<String,String> placeholders) {
|
||||
if (placeholders != null) {
|
||||
map.putAll(placeholders);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,47 @@
|
||||
package com.avaje.ebean.dbmigration.runner;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Created by rob on 1/04/16.
|
||||
* Transforms a SQL script given a map of key/value substitutions.
|
||||
*/
|
||||
public class ScriptTransform {
|
||||
class ScriptTransform {
|
||||
|
||||
/**
|
||||
* Transform just ${table} with the table name.
|
||||
*/
|
||||
public static String table(String tableName, String script) {
|
||||
return script.replace("${table}", tableName);
|
||||
}
|
||||
|
||||
private final Map<String,String> placeholders = new HashMap<String, String>();
|
||||
|
||||
ScriptTransform(Map<String,String> map) {
|
||||
for (Map.Entry<String, String> entry : map.entrySet()) {
|
||||
placeholders.put(wrapKey(entry.getKey()), entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private String wrapKey(String key) {
|
||||
return "${"+key+"}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if this contains no placeholders.
|
||||
*/
|
||||
boolean isEmpty() {
|
||||
return placeholders.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the script replacing placeholders in the form <code>${key}</code> with <code>value</code>.
|
||||
*/
|
||||
String transform(String source) {
|
||||
|
||||
for (Map.Entry<String, String> entry : placeholders.entrySet()) {
|
||||
source = source.replace(entry.getKey(), entry.getValue());
|
||||
}
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.avaje.ebean.EbeanServer;
|
||||
import com.avaje.ebean.config.ServerConfig;
|
||||
import com.avaje.ebean.config.dbplatform.DatabasePlatform;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -40,4 +41,9 @@ public interface SpiServer extends EbeanServer {
|
||||
* Return the bean type for a given doc store queueId.
|
||||
*/
|
||||
BeanType<?> getBeanTypeForQueueId(String queueId);
|
||||
|
||||
/**
|
||||
* Return the associated DataSource for this EbeanServer instance.
|
||||
*/
|
||||
DataSource getDataSource();
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ import com.avaje.ebean.event.readaudit.ReadAuditLogger;
|
||||
import com.avaje.ebean.event.readaudit.ReadAuditPrepare;
|
||||
import com.avaje.ebean.meta.MetaInfoManager;
|
||||
import com.avaje.ebean.plugin.BeanType;
|
||||
import com.avaje.ebean.plugin.SpiServer;
|
||||
import com.avaje.ebean.plugin.Plugin;
|
||||
import com.avaje.ebean.plugin.SpiServer;
|
||||
import com.avaje.ebean.text.csv.CsvReader;
|
||||
import com.avaje.ebean.text.json.JsonContext;
|
||||
import com.avaje.ebeaninternal.api.LoadBeanRequest;
|
||||
@@ -73,6 +73,7 @@ import org.slf4j.LoggerFactory;
|
||||
import javax.persistence.NonUniqueResultException;
|
||||
import javax.persistence.OptimisticLockException;
|
||||
import javax.persistence.PersistenceException;
|
||||
import javax.sql.DataSource;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
@@ -80,7 +81,6 @@ import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.ServiceLoader;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.FutureTask;
|
||||
@@ -308,6 +308,11 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
|
||||
return autoTuneService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataSource getDataSource() {
|
||||
return transactionManager.getDataSource();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReadAuditPrepare getReadAuditPrepare() {
|
||||
return readAuditPrepare;
|
||||
@@ -341,8 +346,9 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
|
||||
migrationConfig.generateOnStart(this);
|
||||
}
|
||||
|
||||
MigrationRunner migrationRunner = new MigrationRunner(this);
|
||||
migrationRunner.run();
|
||||
if (migrationConfig.isRunMigration()) {
|
||||
new MigrationRunner(this, migrationConfig).run();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,6 @@ public final class EncodeUtil {
|
||||
/* no instances */
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* URL-encodes the specified UTF-8 string.
|
||||
*/
|
||||
|
||||
@@ -7,9 +7,10 @@ import java.io.OutputStream;
|
||||
import java.io.Reader;
|
||||
|
||||
/**
|
||||
* Created by rob on 5/02/16.
|
||||
* Utilities for IO.
|
||||
*/
|
||||
public class IOUtils {
|
||||
|
||||
/**
|
||||
* Reads the entire contents of the specified input stream and returns them
|
||||
* as a byte array.
|
||||
@@ -44,6 +45,9 @@ public class IOUtils {
|
||||
return EncodeUtil.decodeBytes(read(in), encoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the entire contents from the reader returning as a String.
|
||||
*/
|
||||
public static String read(Reader reader) throws IOException {
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
@@ -23,4 +23,15 @@ public class JdbcClose {
|
||||
logger.warn("Error closing connection", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback the connection logging if an error occurs.
|
||||
*/
|
||||
public static void rollback(Connection connection) {
|
||||
try {
|
||||
connection.rollback();
|
||||
} catch (SQLException e) {
|
||||
logger.warn("Error on connection rollback", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
create table ${table} (
|
||||
id integer not null,
|
||||
status varchar(10) not null,
|
||||
run_version varchar(150) not null,
|
||||
dep_version varchar(150) not null,
|
||||
comment varchar(150),
|
||||
checksum integer not null,
|
||||
run_on timestamp not null,
|
||||
run_by varchar(30) not null,
|
||||
run_ip varchar(30),
|
||||
constraint pk_${table} primary key (id)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
package com.avaje.ebean.dbmigration;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
import com.avaje.ebean.Ebean;
|
||||
import com.avaje.ebean.EbeanServer;
|
||||
import com.avaje.ebean.config.DbMigrationConfig;
|
||||
import org.junit.Test;
|
||||
|
||||
|
||||
/**
|
||||
* Created by rob on 1/04/16.
|
||||
*/
|
||||
public class MigrationRunnerTest {
|
||||
|
||||
|
||||
@Test
|
||||
public void test() {
|
||||
|
||||
EbeanServer server = Ebean.getDefaultServer();
|
||||
|
||||
DbMigrationConfig config = new DbMigrationConfig();
|
||||
config.setMigrationPath("test-dbmigration");
|
||||
config.setRunMigration(true);
|
||||
config.setDbUser("sa");
|
||||
config.setDbPassword("");
|
||||
|
||||
MigrationRunner runner = new MigrationRunner(server, config);
|
||||
|
||||
runner.run();
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,87 @@
|
||||
package com.avaje.ebean.dbmigration.runner;
|
||||
|
||||
import org.assertj.core.data.MapEntry;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/**
|
||||
* Created by rob on 1/04/16.
|
||||
*/
|
||||
public class PlaceholderBuilderTest {
|
||||
@Test
|
||||
public void build() throws Exception {
|
||||
|
||||
@Test
|
||||
public void empty() throws Exception {
|
||||
|
||||
assertThat(PlaceholderBuilder.build(null, null)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void comma() throws Exception {
|
||||
|
||||
assertThat(PlaceholderBuilder.build("a=1", null))
|
||||
.containsExactly(MapEntry.entry("a","1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void comma_withSpace() throws Exception {
|
||||
|
||||
assertThat(PlaceholderBuilder.build(" a=1 ", null))
|
||||
.containsExactly(MapEntry.entry("a","1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void comma_withSpace_withSemi() throws Exception {
|
||||
|
||||
assertThat(PlaceholderBuilder.build(" a=1 ; ", null))
|
||||
.containsExactly(MapEntry.entry("a","1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void commaPair() throws Exception {
|
||||
|
||||
assertThat(PlaceholderBuilder.build("a=1;b=2", null))
|
||||
.containsExactly(MapEntry.entry("a","1"),MapEntry.entry("b","2"));
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void mapPair() throws Exception {
|
||||
|
||||
Map<String,String> in = new HashMap<String,String>();
|
||||
in.put("a", "1");
|
||||
in.put("b", "2");
|
||||
|
||||
assertThat(PlaceholderBuilder.build(null, in))
|
||||
.containsExactly(MapEntry.entry("a","1"),MapEntry.entry("b","2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void mapPair_override() throws Exception {
|
||||
|
||||
Map<String,String> in = new HashMap<String,String>();
|
||||
in.put("a", "1");
|
||||
in.put("b", "2");
|
||||
|
||||
assertThat(PlaceholderBuilder.build("a=11;b=12", in))
|
||||
.containsExactly(MapEntry.entry("a","1"),MapEntry.entry("b","2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void mapPair_join() throws Exception {
|
||||
|
||||
Map<String,String> in = new HashMap<String,String>();
|
||||
in.put("c", "3");
|
||||
|
||||
assertThat(PlaceholderBuilder.build("a=1;b=2", in))
|
||||
.containsExactly(MapEntry.entry("a","1"),MapEntry.entry("b","2"), MapEntry.entry("c","3"));
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,31 @@ package com.avaje.ebean.dbmigration.runner;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Created by rob on 1/04/16.
|
||||
*/
|
||||
public class ScriptTransformTest {
|
||||
|
||||
@Test
|
||||
public void transform() throws Exception {
|
||||
|
||||
Map<String,String> map = new HashMap<String,String>();
|
||||
map.put("one", "PLACE1");
|
||||
map.put("two", "PLACE2");
|
||||
|
||||
ScriptTransform transform = new ScriptTransform(map);
|
||||
|
||||
assertEquals(transform.transform("${one}"), "PLACE1");
|
||||
assertEquals(transform.transform("${two}"), "PLACE2");
|
||||
assertEquals(transform.transform("${one}${two}"), "PLACE1PLACE2");
|
||||
assertEquals(transform.transform("${two}${one}"), "PLACE2PLACE1");
|
||||
assertEquals(transform.transform("A${one}B${two}C"), "APLACE1BPLACE2C");
|
||||
assertEquals(transform.transform(" ${one} ${two} "), " PLACE1 PLACE2 ");
|
||||
|
||||
assertEquals(transform.transform("$${one}"), "$PLACE1");
|
||||
assertEquals(transform.transform("$${one}}"), "$PLACE1}");
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user