diff --git a/src/Meta/SchemaManager.php b/src/Meta/SchemaManager.php index 876bbe00..da901b48 100644 --- a/src/Meta/SchemaManager.php +++ b/src/Meta/SchemaManager.php @@ -17,6 +17,8 @@ use Reliese\Meta\MySql\Schema as MySqlSchema; use Reliese\Meta\Sqlite\Schema as SqliteSchema; use Reliese\Meta\Postgres\Schema as PostgresSchema; +use Reliese\Meta\SqlServer\Schema as SqlServerSchema; +use Illuminate\Database\SqlServerConnection; class SchemaManager implements IteratorAggregate { @@ -27,6 +29,7 @@ class SchemaManager implements IteratorAggregate MySqlConnection::class => MySqlSchema::class, SQLiteConnection::class => SqliteSchema::class, PostgresConnection::class => PostgresSchema::class, + SqlServerConnection::class => SqlServerSchema::class, \Larapack\DoctrineSupport\Connections\MySqlConnection::class => MySqlSchema::class, \Staudenmeir\LaravelCte\Connections\MySqlConnection::class => MySqlSchema::class, ]; diff --git a/src/Meta/SqlServer/Column.php b/src/Meta/SqlServer/Column.php new file mode 100644 index 00000000..98e96a35 --- /dev/null +++ b/src/Meta/SqlServer/Column.php @@ -0,0 +1,166 @@ + ['varchar', 'nvarchar', 'char', 'nchar', 'text', 'ntext', 'xml', 'uniqueidentifier'], + 'datetime' => ['datetime', 'datetime2', 'datetimeoffset', 'smalldatetime', 'date', 'time'], + 'int' => ['int', 'bigint', 'smallint', 'tinyint', 'bit'], + 'float' => ['decimal', 'numeric', 'real', 'float', 'money', 'smallmoney'], + 'boolean' => ['bit'], + 'binary' => ['binary', 'varbinary', 'image', 'filestream'], + ]; + + /** + * SQLServerColumn constructor. + * + * @param array $metadata + */ + public function __construct($metadata = []) + { + $this->metadata = $metadata; + } + + /** + * @return \Illuminate\Support\Fluent + */ + public function normalize() + { + $attributes = new Fluent(); + + foreach ($this->metas as $meta) { + $this->{'parse' . ucfirst($meta)}($attributes); + } + + return $attributes; + } + + /** + * @param \Illuminate\Support\Fluent $attributes + */ + protected function parseType(Fluent $attributes) + { + $dataType = $this->get('DATA_TYPE', 'varchar'); + $attributes['type'] = $dataType; + + foreach (static::$mappings as $phpType => $database) { + if (in_array($dataType, $database)) { + $attributes['type'] = $phpType; + } + } + + $this->parsePrecision($dataType, $attributes); + } + + /** + * @param string $databaseType + * @param \Illuminate\Support\Fluent $attributes + */ + protected function parsePrecision($databaseType, Fluent $attributes) + { + $precision = $this->get('numeric_precision', null); + $scale = $this->get('numeric_scale', null); + + // Handle boolean/bit special case + if ($databaseType == 'bit') { + $attributes['type'] = 'bool'; + $attributes['size'] = 1; + return; + } + + // Set size and scale for numeric types + if ($precision !== null) { + $attributes['size'] = (int)$precision; + } + + if ($scale !== null) { + $attributes['scale'] = (int)$scale; + } + } + + /** + * @param \Illuminate\Support\Fluent $attributes + */ + protected function parseName(Fluent $attributes) + { + $attributes['name'] = $this->get('column_name'); + } + + /** + * @param \Illuminate\Support\Fluent $attributes + */ + protected function parseAutoincrement(Fluent $attributes) + { + $attributes['autoincrement'] = $this->get('is_identity') === 1; + } + + /** + * @param \Illuminate\Support\Fluent $attributes + */ + protected function parseNullable(Fluent $attributes) + { + $attributes['nullable'] = $this->get('is_nullable') === 1; + } + + /** + * @param \Illuminate\Support\Fluent $attributes + */ + protected function parseDefault(Fluent $attributes) + { + $defaultConstraint = $this->get('column_default', null); + + // Remove surrounding parentheses and potential default constraint syntax + if ($defaultConstraint) { + $defaultConstraint = trim($defaultConstraint, '()'); + $defaultConstraint = preg_replace('/^(N)?\'|\'(N)?$/', '', $defaultConstraint); + } + + $attributes['default'] = $defaultConstraint; + } + + /** + * @param \Illuminate\Support\Fluent $attributes + */ + protected function parseComment(Fluent $attributes) + { + // SQLServer comments are typically stored in extended properties + // This might require additional metadata retrieval + $attributes['comment'] = null; + } + + /** + * @param string $key + * @param mixed $default + * + * @return mixed + */ + protected function get($key, $default = null) + { + return Arr::get($this->metadata, strtoupper($key), $default); + } +} diff --git a/src/Meta/SqlServer/Schema.php b/src/Meta/SqlServer/Schema.php new file mode 100644 index 00000000..e39e7033 --- /dev/null +++ b/src/Meta/SqlServer/Schema.php @@ -0,0 +1,386 @@ +schema_database = Config::get("database.connections.sqlsrv.schema", 'dbo'); + $this->schema = $schema; + $this->connection = $connection; + + $this->load(); + } + + /** + * Loads schema's tables' information from the database. + */ + protected function load() + { + $tables = $this->fetchTables(); + foreach ($tables as $table) { + $blueprint = new Blueprint($this->connection->getName(), $this->schema, $table); + $this->fillColumns($blueprint); + $this->fillConstraints($blueprint); + $this->tables[$table] = $blueprint; + } + $this->loaded = true; + } + + /** + * Fetch tables for the current schema + * + * @return array + */ + protected function fetchTables() + { + $rows = $this->arraify($this->connection->select( + "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES " . + "WHERE TABLE_SCHEMA = '$this->schema_database' AND TABLE_TYPE = 'BASE TABLE'" + )); + return array_column($rows, 'TABLE_NAME'); + } + + /** + * Fill columns for a given blueprint + * + * @param \Reliese\Meta\Blueprint $blueprint + */ + protected function fillColumns(Blueprint $blueprint) + { + $rows = $this->arraify($this->connection->select( + "SELECT * FROM INFORMATION_SCHEMA.COLUMNS " . + "WHERE TABLE_SCHEMA = '$this->schema_database' " . + "AND TABLE_NAME = " . $this->wrap($blueprint->table()) + )); + + foreach ($rows as $column) { + $blueprint->withColumn( + $this->parseColumn($column) + ); + } + } + + /** + * Parse column metadata + * + * @param array $metadata + * @return \Illuminate\Support\Fluent + */ + protected function parseColumn($metadata) + { + return (new Column($metadata))->normalize(); + } + + /** + * Fill constraints for a given blueprint + * + * @param \Reliese\Meta\Blueprint $blueprint + */ + protected function fillConstraints(Blueprint $blueprint) + { + $relations = $this->fetchTableRelations($blueprint->table()); + $this->fillPrimaryKey($relations, $blueprint); + $this->fillRelations($relations, $blueprint); + $this->fillIndexes($blueprint); + } + + /** + * Fetch table relations + * + * @param string $tableName + * @return array + */ + protected function fetchTableRelations($tableName) + { + $sql = " + SELECT + COL_NAME(fk.parent_object_id, fk.parent_column_id) AS column_name, + OBJECT_NAME(fk.referenced_object_id) AS referenced_table, + COL_NAME(fk.referenced_object_id, fk.referenced_column_id) AS referenced_column, + fk.name AS constraint_name, + CASE + WHEN fk.is_primary_key = 1 THEN 'p' + WHEN fk.is_unique_constraint = 1 THEN 'u' + ELSE 'f' + END AS constraint_type + FROM sys.foreign_keys fk + INNER JOIN sys.tables t ON t.object_id = fk.parent_object_id + WHERE t.name = '$tableName' AND SCHEMA_NAME(t.schema_id) = '$this->schema_database' + "; + + return $this->arraify($this->connection->select($sql)); + } + + /** + * Fill primary key for blueprint + * + * @param array $relations + * @param \Reliese\Meta\Blueprint $blueprint + */ + protected function fillPrimaryKey($relations, Blueprint $blueprint) + { + $pk = []; + foreach ($relations as $row) { + if ($row['constraint_type'] === 'p') { + $pk[] = $row['column_name']; + } + } + + if (!empty($pk)) { + $key = [ + 'name' => 'primary', + 'index' => '', + 'columns' => $pk, + ]; + + $blueprint->withPrimaryKey(new Fluent($key)); + } + } + + /** + * Fill indexes for blueprint + * + * @param \Reliese\Meta\Blueprint $blueprint + */ + protected function fillIndexes(Blueprint $blueprint) + { + $indexSql = " + SELECT + i.name AS index_name, + COL_NAME(ic.object_id, ic.column_id) AS column_name, + i.is_unique, + i.is_primary_key + FROM sys.indexes i + INNER JOIN sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id + INNER JOIN sys.tables t ON t.object_id = i.object_id + WHERE t.name = '{$blueprint->table()}' + AND SCHEMA_NAME(t.schema_id) = '{$this->schema_database}' + AND i.is_primary_key = 0 + "; + + $indexes = $this->arraify($this->connection->select($indexSql)); + + $processedIndexes = []; + foreach ($indexes as $index) { + $indexName = $index['index_name']; + + if (!isset($processedIndexes[$indexName])) { + $processedIndexes[$indexName] = [ + 'name' => $index['is_unique'] ? 'unique' : 'index', + 'columns' => [$index['column_name']], + 'index' => $indexName, + ]; + } else { + $processedIndexes[$indexName]['columns'][] = $index['column_name']; + } + } + + foreach ($processedIndexes as $indexData) { + $blueprint->withIndex(new Fluent($indexData)); + } + } + + /** + * Fill relations for blueprint + * + * @param array $relations + * @param \Reliese\Meta\Blueprint $blueprint + */ + protected function fillRelations($relations, Blueprint $blueprint) + { + $fk = []; + foreach ($relations as $row) { + if ($row['constraint_type'] === 'f') { + $relName = $row['constraint_name']; + if (!array_key_exists($relName, $fk)) { + $fk[$relName] = [ + 'columns' => [], + 'ref' => [], + ]; + } + $fk[$relName]['columns'][] = $row['column_name']; + $fk[$relName]['ref'][] = $row['referenced_column']; + $fk[$relName]['table'] = $row['referenced_table']; + } + } + + foreach ($fk as $row) { + $relation = [ + 'name' => 'foreign', + 'index' => '', + 'columns' => $row['columns'], + 'references' => $row['ref'], + 'on' => [$this->schema, $row['table']], + ]; + + $blueprint->withRelation(new Fluent($relation)); + } + } + + /** + * Quick conversion of database results to array + * + * @param $data + * @return mixed + */ + protected function arraify($data) + { + return json_decode(json_encode($data), true); + } + + /** + * Wrap values for SQL queries + * + * @param string $table + * @return string + */ + protected function wrap($table) + { + $pieces = explode('.', str_replace('\'', '', $table)); + return implode('.', array_map(function ($piece) { + return "'$piece'"; + }, $pieces)); + } + + /** + * Get available schemas/databases + * + * @param \Illuminate\Database\Connection $connection + * @return array + */ + public static function schemas(Connection $connection) + { + $schemas = $connection->select('SELECT name FROM sys.databases'); + $schemas = array_column($schemas, 'name'); + + return array_diff($schemas, [ + 'master', + 'tempdb', + 'model', + 'msdb' + ]); + } + + /** + * Get current schema + * + * @return string + */ + public function schema() + { + return $this->schema; + } + + /** + * Check if table exists in schema + * + * @param string $table + * @return bool + */ + public function has($table) + { + return array_key_exists($table, $this->tables); + } + + /** + * Get all tables + * + * @return \Reliese\Meta\Blueprint[] + */ + public function tables() + { + return $this->tables; + } + + /** + * Get specific table + * + * @param string $table + * @return \Reliese\Meta\Blueprint + * @throws \InvalidArgumentException + */ + public function table($table) + { + if (!$this->has($table)) { + throw new \InvalidArgumentException("Table [$table] does not belong to schema [{$this->schema}]"); + } + + return $this->tables[$table]; + } + + /** + * Get connection + * + * @return \Illuminate\Database\Connection + */ + public function connection() + { + return $this->connection; + } + + /** + * Find tables referencing a given table + * + * @param \Reliese\Meta\Blueprint $table + * @return array + */ + public function referencing(Blueprint $table) + { + $references = []; + + foreach ($this->tables as $blueprint) { + foreach ($blueprint->references($table) as $reference) { + $references[] = [ + 'blueprint' => $blueprint, + 'reference' => $reference, + ]; + } + } + + return $references; + } +}