├── .github └── FUNDING.yml ├── LICENSE ├── bin └── smartdump ├── composer.json └── src ├── Configuration ├── DumpConfiguration.php └── TargetTable.php ├── Driver.php ├── Driver └── MySQLDriver.php ├── DriverCache.php ├── Dumper.php ├── Object ├── ForeignKey.php └── Table.php └── Workset.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: BenMorel 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020-present Benjamin Morel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /bin/smartdump: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | table = new Table($schema, $table); 79 | $targetTable->conditions = $conditions; 80 | 81 | return $targetTable; 82 | } 83 | 84 | $command = function(InputInterface $input, OutputInterface $output): void { 85 | /** @var string $host */ 86 | $host = $input->getOption('host'); 87 | 88 | /** @var string $port */ 89 | $port = $input->getOption('port'); 90 | 91 | /** @var string $user */ 92 | $user = $input->getOption('user'); 93 | 94 | /** @var string $password */ 95 | $password = $input->getOption('password'); 96 | 97 | /** @var string $charset */ 98 | $charset = $input->getOption('charset'); 99 | 100 | $dsn = sprintf('mysql:host=%s;port=%s;charset=%s', $host, $port, $charset); 101 | 102 | $pdo = new PDO($dsn, $user, $password); 103 | $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); 104 | 105 | $driver = new MySQLDriver($pdo); 106 | $dumper = new Dumper($pdo, $driver); 107 | 108 | /** @var string|null $database */ 109 | $database = $input->getOption('database'); 110 | 111 | /** @var string[] $tables */ 112 | $tables = $input->getArgument('tables'); 113 | 114 | $targetTables = array_map( 115 | fn(string $table) => createTargetTable($table, $database), 116 | $tables 117 | ); 118 | 119 | $noCreateTable = (bool) $input->getOption('no-create-table'); 120 | $addDropTable = (bool) $input->getOption('add-drop-table'); 121 | $noSchemaName = (bool) $input->getOption('no-schema-name'); 122 | $merge = (bool) $input->getOption('merge'); 123 | 124 | $config = new DumpConfiguration(); 125 | 126 | $config->targetTables = $targetTables; 127 | $config->addCreateTable = ! $noCreateTable; 128 | $config->addDropTable = $addDropTable; 129 | $config->includeSchemaNameInOutput = ! $noSchemaName; 130 | $config->merge = $merge; 131 | 132 | $statements = $dumper->dump($config); 133 | 134 | foreach ($statements as $statement) { 135 | $output->writeln($statement); 136 | } 137 | }; 138 | 139 | (new SingleCommandApplication()) 140 | ->setName('Smart Dump') 141 | ->addArgument('tables', InputArgument::IS_ARRAY, 'The table names, separated with spaces, as schema.table, or just the table name if --database is provided') 142 | ->addOption('host', null, InputOption::VALUE_REQUIRED, 'The host name', 'localhost') 143 | ->addOption('port', null, InputOption::VALUE_REQUIRED, 'The port number', '3306') 144 | ->addOption('user', null, InputOption::VALUE_REQUIRED, 'The user name;', 'root') 145 | ->addOption('password', null, InputOption::VALUE_REQUIRED, 'The password', '') 146 | ->addOption('charset', null, InputOption::VALUE_REQUIRED, 'The connection charset', 'utf8mb4') 147 | ->addOption('database', null, InputOption::VALUE_REQUIRED, 'Accept table names as arguments, in the given database') 148 | ->addOption('no-create-table', null, InputOption::VALUE_NONE, 'Add this option to not include a CREATE TABLE statement') 149 | ->addOption('add-drop-table', null, InputOption::VALUE_NONE, 'Add this option to include a DROP TABLE IF EXISTS statement before CREATE TABLE') 150 | ->addOption('no-schema-name', null, InputOption::VALUE_NONE, 'Add this option to not include the schema name in the output') 151 | ->addOption('merge', null, InputOption::VALUE_NONE, 'Add this option to create a dump that can be merged into an existing schema, using an upsert. Implies --no-create-table') 152 | ->setCode($command) 153 | ->run(); 154 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "benmorel/smartdump", 3 | "description": "Dumps selected MySQL tables together with their relationships", 4 | "license": "MIT", 5 | "require": { 6 | "php": "^7.4 || ^8.0", 7 | "ext-pdo": "*", 8 | "symfony/console": "^5.1" 9 | }, 10 | "require-dev": { 11 | "vimeo/psalm": "^4.1" 12 | }, 13 | "autoload": { 14 | "psr-4": { 15 | "BenMorel\\SmartDump\\": "src/" 16 | } 17 | }, 18 | "bin": ["bin/smartdump"] 19 | } 20 | -------------------------------------------------------------------------------- /src/Configuration/DumpConfiguration.php: -------------------------------------------------------------------------------- 1 | ` query. 20 | * 21 | * Some examples: 22 | * - "LIMIT 10" 23 | * - "WHERE user_id = 123" 24 | * - "WHERE user_id = 123 ORDER BY id DESC LIMIT 10" 25 | */ 26 | public ?string $conditions; 27 | } 28 | -------------------------------------------------------------------------------- /src/Driver.php: -------------------------------------------------------------------------------- 1 | $row 70 | * 71 | * @param string $table The quoted table name. 72 | * @param array $row The row, as an associative array of column names to values. 73 | */ 74 | public function getUpsertSQL(string $table, array $row): string; 75 | 76 | /** 77 | * Quotes an identifier such as a table name or field name. 78 | * 79 | * Example for MySQL: 'foo' => '`foo`' 80 | */ 81 | public function quoteIdentifier(string $name): string; 82 | 83 | /** 84 | * Returns a quoted table identifier for the given table. 85 | * 86 | * Example for MySQL: '`schema`.`table`' 87 | */ 88 | public function getTableIdentifier(Table $table): string; 89 | 90 | /** 91 | * Quotes the given value, if required, to be able to use it as is in an INSERT statement. 92 | * 93 | * @param scalar|null $value 94 | */ 95 | public function quoteValue($value): string; 96 | } 97 | -------------------------------------------------------------------------------- /src/Driver/MySQLDriver.php: -------------------------------------------------------------------------------- 1 | pdo = $pdo; 19 | } 20 | 21 | public function beginTransaction(): void 22 | { 23 | $this->pdo->exec('SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE'); 24 | $this->pdo->exec('START TRANSACTION READ ONLY'); 25 | } 26 | 27 | public function endTransaction(): void 28 | { 29 | $this->pdo->exec('COMMIT'); 30 | } 31 | 32 | public function getPrimaryKeyColumns(Table $table): array 33 | { 34 | $statement = $this->pdo->prepare(<<execute([$table->schema, $table->name]); 44 | 45 | /** @var string[] $rows */ 46 | $rows = $statement->fetchAll(PDO::FETCH_COLUMN); 47 | 48 | return $rows; 49 | } 50 | 51 | /** 52 | * @psalm-suppress InaccessibleProperty https://github.com/vimeo/psalm/issues/4606 53 | */ 54 | public function getForeignKeys(Table $table): array 55 | { 56 | $statement = $this->pdo->prepare(<<execute([$table->schema . '/' . $table->name]); 64 | 65 | /** @psalm-var list $rows */ 66 | $rows = $statement->fetchAll(PDO::FETCH_ASSOC); 67 | 68 | $foreignKeys = []; 69 | 70 | foreach ($rows as $row) { 71 | [$refTableSchema, $refTableName] = explode('/', $row['REF_NAME']); 72 | [$fkSchema, $fkName] = explode('/', $row['ID']); 73 | 74 | $foreignKey = new ForeignKey(); 75 | 76 | $foreignKey->schema = $fkSchema; 77 | $foreignKey->name = $fkName; 78 | $foreignKey->table = $table; 79 | $foreignKey->referencedTable = new Table($refTableSchema, $refTableName); 80 | $foreignKey->columns = $this->getForeignKeyColumns($row['ID']); 81 | 82 | $foreignKey->targetsPrimaryKey = $this->areColumnsTheSame( 83 | $foreignKey->columns, 84 | $this->getPrimaryKeyColumns($foreignKey->referencedTable) 85 | ); 86 | 87 | $foreignKeys[] = $foreignKey; 88 | } 89 | 90 | return $foreignKeys; 91 | } 92 | 93 | /** 94 | * @psalm-return non-empty-array 95 | */ 96 | private function getForeignKeyColumns(string $fkID): array 97 | { 98 | $statement = $this->pdo->prepare(<<execute([$fkID]); 106 | 107 | /** @psalm-var list $rows */ 108 | $rows = $statement->fetchAll(PDO::FETCH_ASSOC); 109 | 110 | assert(count($rows) !== 0); 111 | 112 | $result = []; 113 | 114 | foreach ($rows as $row) { 115 | $result[$row['FOR_COL_NAME']] = $row['REF_COL_NAME']; 116 | } 117 | 118 | return $result; 119 | } 120 | 121 | /** 122 | * Checks if the list of columns are the same, even if not in the same order. 123 | * 124 | * @param string[] $a 125 | * @param string[] $b 126 | * 127 | * @return bool 128 | */ 129 | private function areColumnsTheSame(array $a, array $b): bool 130 | { 131 | $a = array_values($a); 132 | $b = array_values($b); 133 | 134 | sort($a); 135 | sort($b); 136 | 137 | return $a === $b; 138 | } 139 | 140 | public function getCreateTableSQL(Table $table, bool $schemaNameInOutput): string 141 | { 142 | $statement = $this->pdo->query('SHOW CREATE TABLE ' . $this->getTableIdentifier($table)); 143 | 144 | /** @var string $sql */ 145 | $sql = $statement->fetchColumn(1); 146 | 147 | assert(strpos($sql, 'CREATE TABLE') === 0); 148 | 149 | // output does not contain a semicolon 150 | $sql .= ';'; 151 | 152 | if ($schemaNameInOutput) { 153 | // MySQL never includes the schema name in the SHOW CREATE TABLE output. 154 | 155 | $schema = $this->quoteIdentifier($table->schema); 156 | 157 | $sql = preg_replace('/^CREATE TABLE /', 'CREATE TABLE ' . $schema . '.', $sql); 158 | } 159 | 160 | // Constraints may or may not contain the schema of the target table, depending on whether or not the tables are 161 | // in the same schema. We need to rewrite the REFERENCES part to ensure that we comply with $schemaNameInOutput. 162 | // A regexp is not completely foolproof for this purpose, but unless table or column names contain crazy 163 | // characters like backticks, dots or parentheses (unfortunately they can), this will work. 164 | 165 | $regexp = '/(CONSTRAINT .+? FOREIGN KEY .+? REFERENCES )(?:`(.+?)`(?:\.`(.+?)`)?)( \(.+?\))/'; 166 | 167 | $sql = preg_replace_callback($regexp, function(array $matches) use ($table, $schemaNameInOutput) { 168 | /** @psalm-var list $matches */ 169 | [, $before, $a, $b, $after] = $matches; 170 | 171 | if ($b === '') { 172 | $schemaName = $table->schema; 173 | $tableName = $a; 174 | } else { 175 | $schemaName = $a; 176 | $tableName = $b; 177 | } 178 | 179 | $quotedTableName = $this->quoteIdentifier($tableName); 180 | 181 | if ($schemaNameInOutput) { 182 | $quotedTableName = $this->quoteIdentifier($schemaName) . '.' . $quotedTableName; 183 | } 184 | 185 | return $before . $quotedTableName . $after; 186 | }, $sql); 187 | 188 | return $sql; 189 | } 190 | 191 | public function getDropTableIfExistsSQL(string $table): string 192 | { 193 | return 'DROP TABLE IF EXISTS ' . $table . ';'; 194 | } 195 | 196 | public function getDisableForeignKeysSQL(): string 197 | { 198 | return 'SET foreign_key_checks = 0;'; 199 | } 200 | 201 | public function getEnableForeignKeysSQL(): string 202 | { 203 | return 'SET foreign_key_checks = 1;'; 204 | } 205 | 206 | public function getUpsertSQL(string $table, array $row): string 207 | { 208 | $values = []; 209 | 210 | foreach ($row as $key => $value) { 211 | $values[] = $this->quoteIdentifier($key) . ' = ' . $this->quoteValue($value); 212 | } 213 | 214 | $values = implode(', ', $values); 215 | 216 | return sprintf('INSERT INTO %s SET %s ON DUPLICATE KEY UPDATE %s;', $table, $values, $values); 217 | } 218 | 219 | public function quoteIdentifier(string $name): string 220 | { 221 | return '`' . str_replace('`', '``', $name) . '`'; 222 | } 223 | 224 | public function getTableIdentifier(Table $table): string 225 | { 226 | return $this->quoteIdentifier($table->schema) . '.' . $this->quoteIdentifier($table->name); 227 | } 228 | 229 | public function quoteValue($value): string 230 | { 231 | if ($value === null) { 232 | return 'NULL'; 233 | } 234 | 235 | if (is_bool($value)) { 236 | return $value ? '1' : '0'; 237 | } 238 | 239 | if (is_int($value) || is_float($value)) { 240 | return (string) $value; 241 | } 242 | 243 | return $this->pdo->quote($value); 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/DriverCache.php: -------------------------------------------------------------------------------- 1 | > 21 | */ 22 | private array $primaryKeyColumns = []; 23 | 24 | /** 25 | * A cache of the foreign keys of each table, indexed by schema name and table name. 26 | * 27 | * @psalm-var array> 28 | */ 29 | private array $foreignKeys = []; 30 | 31 | public function __construct(Driver $driver) 32 | { 33 | $this->driver = $driver; 34 | } 35 | 36 | /** 37 | * @return string[] 38 | */ 39 | public function getPrimaryKeyColumns(Table $table): array 40 | { 41 | if (isset($this->primaryKeyColumns[$table->schema][$table->name])) { 42 | return $this->primaryKeyColumns[$table->schema][$table->name]; 43 | } 44 | 45 | return $this->primaryKeyColumns[$table->schema][$table->name] = $this->driver->getPrimaryKeyColumns($table); 46 | } 47 | 48 | /** 49 | * @return ForeignKey[] 50 | */ 51 | public function getForeignKeys(Table $table): array 52 | { 53 | if (isset($this->foreignKeys[$table->schema][$table->name])) { 54 | return $this->foreignKeys[$table->schema][$table->name]; 55 | } 56 | 57 | return $this->foreignKeys[$table->schema][$table->name] = $this->driver->getForeignKeys($table); 58 | } 59 | } 60 | 61 | -------------------------------------------------------------------------------- /src/Dumper.php: -------------------------------------------------------------------------------- 1 | pdo = $pdo; 25 | $this->driver = $driver; 26 | $this->driverCache = new DriverCache($driver); 27 | } 28 | 29 | /** 30 | * Dumps the given tables and all their relationships. 31 | * 32 | * Only the given tables will be dumped in full, related tables that are not in the given set of tables will only 33 | * contain the required rows to satisfy the foreign keys. 34 | * 35 | * Every string returned by the generator is a SQL statement. 36 | * 37 | * Note that we're iterating the tables twice, once to generate the workset, and once to dump the rows. We're also 38 | * reading rows in the workset one by one; these are two areas that can probably be improved to make the dump 39 | * faster. 40 | * 41 | * @param Table[] $tables The base tables to dump. 42 | * 43 | * @return Generator 44 | */ 45 | public function dump(DumpConfiguration $config): Generator 46 | { 47 | $this->driver->beginTransaction(); 48 | 49 | $workset = $this->generateWorkset($config); 50 | 51 | // even though our export should be referentially intact, our inserts are not necessarily performed in the right 52 | // order, so we disable foreign key checks first! 53 | yield $this->driver->getDisableForeignKeysSQL(); 54 | 55 | foreach ($workset->getTables() as $table) { 56 | $tableName = $config->includeSchemaNameInOutput 57 | ? $this->driver->getTableIdentifier($table) 58 | : $this->driver->quoteIdentifier($table->name); 59 | 60 | if ($config->addDropTable && ! $config->merge) { 61 | yield $this->driver->getDropTableIfExistsSQL($tableName); 62 | } 63 | 64 | if ($config->addCreateTable && ! $config->merge) { 65 | yield $this->driver->getCreateTableSQL($table, $config->includeSchemaNameInOutput); 66 | } 67 | 68 | foreach ($workset->getPrimaryKeyIds($table) as $primaryKeyId) { 69 | $row = $this->readRow($table, $primaryKeyId); 70 | 71 | if ($config->merge) { 72 | yield $this->driver->getUpsertSQL($tableName, $row); 73 | } else { 74 | yield $this->getInsertSQL($tableName, $row); 75 | } 76 | } 77 | } 78 | 79 | yield $this->driver->getEnableForeignKeysSQL(); 80 | 81 | $this->driver->endTransaction(); 82 | } 83 | 84 | private function generateWorkset(DumpConfiguration $config): Workset 85 | { 86 | $workset = new Workset(); 87 | 88 | // add requested tables to ensure that their structure will be exported even if they're empty 89 | foreach ($config->targetTables as $targetTable) { 90 | $workset->addTable($targetTable->table); 91 | } 92 | 93 | // iterate recursively over the table rows; these will add extra tables together with the row if required 94 | foreach ($config->targetTables as $targetTable) { 95 | foreach ($this->readTargetTable($targetTable) as $row) { 96 | $this->addRowToWorkset($workset, $targetTable->table, $row); 97 | } 98 | } 99 | 100 | return $workset; 101 | } 102 | 103 | /** 104 | * @psalm-param non-empty-array $row 105 | */ 106 | private function addRowToWorkset(Workset $workset, Table $table, array $row): void 107 | { 108 | $primaryKeyColumns = $this->driverCache->getPrimaryKeyColumns($table); 109 | 110 | $primaryKeyId = []; 111 | 112 | foreach ($primaryKeyColumns as $column) { 113 | $primaryKeyId[$column] = $row[$column]; 114 | } 115 | 116 | /** @psalm-var non-empty-array $primaryKeyId */ 117 | if (! $workset->addRow($table, $primaryKeyId)) { 118 | // row already processed 119 | return; 120 | } 121 | 122 | // this is the first time we encounter this row, follow its relationships 123 | $foreignKeys = $this->driverCache->getForeignKeys($table); 124 | 125 | foreach ($foreignKeys as $foreignKey) { 126 | $refId = []; 127 | 128 | foreach ($foreignKey->columns as $columnName => $refColumnName) { 129 | if ($row[$columnName] === null) { 130 | // no foreign record 131 | continue 2; 132 | } 133 | 134 | $refId[$refColumnName] = $row[$columnName]; 135 | } 136 | 137 | /** @psalm-var non-empty-array $refId */ 138 | $refRow = $this->readRow($foreignKey->referencedTable, $refId, $table); 139 | 140 | $this->addRowToWorkset($workset, $foreignKey->referencedTable, $refRow); 141 | } 142 | } 143 | 144 | /** 145 | * Reads the target table, yielding rows as associative arrays. 146 | * 147 | * @psalm-return Generator> 148 | */ 149 | private function readTargetTable(TargetTable $targetTable): Generator 150 | { 151 | $query = 'SELECT * FROM ' . $this->driver->getTableIdentifier($targetTable->table); 152 | 153 | if ($targetTable->conditions !== null) { 154 | $query .= ' ' . $targetTable->conditions; 155 | } 156 | 157 | $statement = $this->pdo->query($query); 158 | 159 | while (false !== $row = $statement->fetch(PDO::FETCH_ASSOC)) { 160 | /** @psalm-var non-empty-array $row */ 161 | yield $row; 162 | } 163 | } 164 | 165 | /** 166 | * Reads a single row as an associative array of column name to value. 167 | * 168 | * The $uniqueId is the identifier of the row as an associative array of column name to value. 169 | * The column names must match the primary key or a unique key of the table. 170 | * 171 | * @psalm-param non-empty-array $uniqueId 172 | * 173 | * @psalm-return non-empty-array 174 | * 175 | * @param Table|null $fkTable The table on which the foreign key to the requested row has been found, if any. 176 | * If $fkTable is null, this means that we should be reading a row that we have already 177 | * read before, so it should be always available. If $fkTable is not null, then the row 178 | * can only be absent if a foreign key constraint is broken. 179 | * 180 | * @throws RuntimeException If there is not exactly one row found. 181 | */ 182 | public function readRow(Table $table, array $uniqueId, ?Table $fkTable = null): array 183 | { 184 | $conditions = []; 185 | $values = []; 186 | 187 | foreach ($uniqueId as $name => $value) { 188 | $conditions[] = $this->driver->quoteIdentifier($name) . ' = ?'; 189 | $values[] = $value; 190 | } 191 | 192 | $query = sprintf( 193 | 'SELECT * FROM %s WHERE %s', 194 | $this->driver->getTableIdentifier($table), 195 | implode(' AND ', $conditions) 196 | ); 197 | 198 | $statement = $this->pdo->prepare($query); 199 | $statement->execute($values); 200 | 201 | /** @psalm-var list> $rows */ 202 | $rows = $statement->fetchAll(PDO::FETCH_ASSOC); 203 | 204 | $count = count($rows); 205 | 206 | if ($count !== 1) { 207 | if ($count === 0) { 208 | if ($fkTable !== null) { 209 | throw $this->targetRowNotFound($table, $uniqueId, $fkTable); 210 | } 211 | 212 | throw new RuntimeException( 213 | 'Could not re-read previously read row by primary key; ' . 214 | 'this should not be possible.' 215 | ); 216 | } 217 | 218 | throw new RuntimeException( 219 | 'Found more than 1 row matching the foreign key; ' . 220 | 'this should not be possible as we should be targeting a primary key or a unique key.'); 221 | } 222 | 223 | return $rows[0]; 224 | } 225 | 226 | /** 227 | * @psalm-param non-empty-array $uniqueId 228 | */ 229 | private function targetRowNotFound(Table $table, array $uniqueId, Table $fkTable): RuntimeException 230 | { 231 | return new RuntimeException(sprintf( 232 | 'Found a broken foreign key constraint: %s.%s to %s.%s with %s; ' . 233 | 'the target row does not exist. Aborting.', 234 | $fkTable->schema, 235 | $fkTable->name, 236 | $table->schema, 237 | $table->name, 238 | implode(', ', array_map(function ($name, $value) { 239 | return $name . '=' . var_export($value, true); 240 | }, array_keys($uniqueId), $uniqueId)) 241 | )); 242 | } 243 | 244 | /** 245 | * Returns a SQL statement to create the given row. 246 | * 247 | * @psalm-param non-empty-array $row 248 | * 249 | * @param string $table The quoted table name. 250 | * @param array $row The row, as an associative array of column names to values. 251 | */ 252 | private function getInsertSQL(string $table, array $row): string 253 | { 254 | $keys = []; 255 | $values = []; 256 | 257 | foreach ($row as $key => $value) { 258 | $keys[] = $this->driver->quoteIdentifier($key); 259 | $values[] = $this->driver->quoteValue($value); 260 | } 261 | 262 | return sprintf('INSERT INTO %s (%s) VALUES (%s);', $table, implode(', ', $keys), implode(', ', $values)); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/Object/ForeignKey.php: -------------------------------------------------------------------------------- 1 | 38 | */ 39 | public array $columns; 40 | 41 | /** 42 | * Whether the referenced column names match the table's primary key. 43 | * 44 | * True if they match the primary key, false if they match a unique key. 45 | */ 46 | public bool $targetsPrimaryKey; 47 | } 48 | -------------------------------------------------------------------------------- /src/Object/Table.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 25 | $this->name = $name; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Workset.php: -------------------------------------------------------------------------------- 1 | >>> 21 | */ 22 | private array $primaryKeyIds = []; 23 | 24 | /** 25 | * A map of hash of existing rows to true. 26 | * 27 | * This is a quick & dirty way to check if a row is already in the workset. 28 | * Hashes are currently a serialized string of schema name, table name and primary key id. 29 | * 30 | * @psalm-var array 31 | */ 32 | private array $hashes = []; 33 | 34 | /** 35 | * A map of hash to Table instance. 36 | * 37 | * This allows to quickly check if a table is already set. 38 | * Hashes are currently a serialized string of schema name, table name. 39 | * 40 | * @var array 41 | */ 42 | private array $tables = []; 43 | 44 | /** 45 | * Adds a table to the workset, to ensure that its structure will be exported. 46 | * 47 | * This only needs to be called for explicitly requested tables, to ensure that we'll always export their structure, 48 | * even if they're empty. Other tables will only be included in the workset if at least one row is required. 49 | * 50 | * @param Table $table 51 | */ 52 | public function addTable(Table $table): void 53 | { 54 | $hash = serialize([$table->schema, $table->name]); 55 | 56 | if (! isset($this->tables[$hash])) { 57 | $this->tables[$hash] = $table; 58 | } 59 | } 60 | 61 | /** 62 | * Adds a table row to the workset. 63 | * 64 | * The $id is the identifier of the row as an associative array of column name to value. 65 | * The column names must match the primary key of the table, and be ordered according to the PK order. 66 | * 67 | * Return true if the row was added to the workset, false if it already existed. 68 | * 69 | * @psalm-param non-empty-array $primaryKeyId 70 | */ 71 | public function addRow(Table $table, array $primaryKeyId): bool 72 | { 73 | $hash = serialize([$table->schema, $table->name, $primaryKeyId]); 74 | 75 | if (isset($this->hashes[$hash])) { 76 | return false; 77 | } 78 | 79 | $this->addTable($table); 80 | 81 | $this->primaryKeyIds[$table->schema][$table->name][] = $primaryKeyId; 82 | $this->hashes[$hash] = true; 83 | 84 | return true; 85 | } 86 | 87 | /** 88 | * Returns the tables in the workset. 89 | * 90 | * @return Table[] 91 | */ 92 | public function getTables(): array 93 | { 94 | return array_values($this->tables); 95 | } 96 | 97 | /** 98 | * Returns the primary key ids of the rows in the workset for the given table. 99 | * 100 | * Each identifier is an associative array of column name to value, that match the primary key of the table. 101 | * 102 | * @psalm-return list> 103 | */ 104 | public function getPrimaryKeyIds(Table $table): array 105 | { 106 | return $this->primaryKeyIds[$table->schema][$table->name] ?? []; 107 | } 108 | } 109 | --------------------------------------------------------------------------------