feat(mssql): add MSSQL writer for generating DDL from database schema
All checks were successful
All checks were successful
- Implement MSSQL writer to generate SQL scripts for creating schemas, tables, and constraints. - Support for identity columns, indexes, and extended properties. - Add tests for column definitions, table creation, primary keys, foreign keys, and comments. - Include testing guide and sample schema for integration tests.
This commit is contained in:
130
pkg/writers/mssql/README.md
Normal file
130
pkg/writers/mssql/README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# MSSQL Writer
|
||||
|
||||
Generates Microsoft SQL Server DDL (Data Definition Language) from database schema models.
|
||||
|
||||
## Features
|
||||
|
||||
- **DDL Generation**: Generates complete SQL scripts for creating MSSQL schema
|
||||
- **Schema Support**: Creates multiple schemas with proper naming
|
||||
- **Bracket Notation**: Uses [schema].[table] bracket notation for identifiers
|
||||
- **Identity Columns**: Generates IDENTITY(1,1) for auto-increment columns
|
||||
- **Constraints**: Generates primary keys, foreign keys, unique, and check constraints
|
||||
- **Indexes**: Creates indexes with unique support
|
||||
- **Extended Properties**: Uses sp_addextendedproperty for comments
|
||||
- **Direct Execution**: Can directly execute DDL on MSSQL database
|
||||
- **Schema Flattening**: Optional schema flattening for compatibility
|
||||
|
||||
## Features by Phase
|
||||
|
||||
1. **Phase 1**: Create schemas
|
||||
2. **Phase 2**: Create tables with columns, identity, and defaults
|
||||
3. **Phase 3**: Add primary key constraints
|
||||
4. **Phase 4**: Create indexes
|
||||
5. **Phase 5**: Add unique constraints
|
||||
6. **Phase 6**: Add check constraints
|
||||
7. **Phase 7**: Add foreign key constraints
|
||||
8. **Phase 8**: Add extended properties (comments)
|
||||
|
||||
## Type Mappings
|
||||
|
||||
| Canonical Type | MSSQL Type |
|
||||
|----------------|-----------|
|
||||
| int | INT |
|
||||
| int64 | BIGINT |
|
||||
| int16 | SMALLINT |
|
||||
| int8 | TINYINT |
|
||||
| bool | BIT |
|
||||
| float32 | REAL |
|
||||
| float64 | FLOAT |
|
||||
| decimal | NUMERIC |
|
||||
| string | NVARCHAR(255) |
|
||||
| text | NVARCHAR(MAX) |
|
||||
| timestamp | DATETIME2 |
|
||||
| timestamptz | DATETIMEOFFSET |
|
||||
| uuid | UNIQUEIDENTIFIER |
|
||||
| bytea | VARBINARY(MAX) |
|
||||
| date | DATE |
|
||||
| time | TIME |
|
||||
|
||||
## Usage
|
||||
|
||||
### Generate SQL File
|
||||
|
||||
```go
|
||||
import "git.warky.dev/wdevs/relspecgo/pkg/writers/mssql"
|
||||
import "git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
|
||||
writer := mssql.NewWriter(&writers.WriterOptions{
|
||||
OutputPath: "schema.sql",
|
||||
FlattenSchema: false,
|
||||
})
|
||||
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
```
|
||||
|
||||
### Direct Database Execution
|
||||
|
||||
```go
|
||||
writer := mssql.NewWriter(&writers.WriterOptions{
|
||||
OutputPath: "",
|
||||
Metadata: map[string]interface{}{
|
||||
"connection_string": "sqlserver://sa:password@localhost/newdb",
|
||||
},
|
||||
})
|
||||
|
||||
err := writer.WriteDatabase(db)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
```
|
||||
|
||||
### CLI Usage
|
||||
|
||||
Generate SQL file:
|
||||
```bash
|
||||
relspec convert --from json --from-path schema.json \
|
||||
--to mssql --to-path schema.sql
|
||||
```
|
||||
|
||||
Execute directly to database:
|
||||
```bash
|
||||
relspec convert --from json --from-path schema.json \
|
||||
--to mssql \
|
||||
--metadata '{"connection_string":"sqlserver://sa:password@localhost/mydb"}'
|
||||
```
|
||||
|
||||
## Default Values
|
||||
|
||||
The writer supports several default value patterns:
|
||||
- Functions: `GETDATE()`, `CURRENT_TIMESTAMP()`
|
||||
- Literals: strings wrapped in quotes, numbers, booleans (0/1 for BIT)
|
||||
- CAST expressions
|
||||
|
||||
## Comments/Extended Properties
|
||||
|
||||
Table and column descriptions are stored as MS_Description extended properties:
|
||||
|
||||
```sql
|
||||
EXEC sp_addextendedproperty
|
||||
@name = 'MS_Description',
|
||||
@value = 'Table description here',
|
||||
@level0type = 'SCHEMA', @level0name = 'dbo',
|
||||
@level1type = 'TABLE', @level1name = 'my_table';
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests with:
|
||||
```bash
|
||||
go test ./pkg/writers/mssql/...
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- Views are not currently supported in the writer
|
||||
- Sequences are not supported (MSSQL uses IDENTITY instead)
|
||||
- Partitioning and advanced features are not supported
|
||||
- Generated DDL assumes no triggers or computed columns
|
||||
579
pkg/writers/mssql/writer.go
Normal file
579
pkg/writers/mssql/writer.go
Normal file
@@ -0,0 +1,579 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
_ "github.com/microsoft/go-mssqldb" // MSSQL driver
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/mssql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
// Writer implements the Writer interface for MSSQL SQL output
|
||||
type Writer struct {
|
||||
options *writers.WriterOptions
|
||||
writer io.Writer
|
||||
}
|
||||
|
||||
// NewWriter creates a new MSSQL SQL writer
|
||||
func NewWriter(options *writers.WriterOptions) *Writer {
|
||||
return &Writer{
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
// qualTable returns a schema-qualified name using bracket notation
|
||||
func (w *Writer) qualTable(schema, name string) string {
|
||||
if w.options.FlattenSchema {
|
||||
return fmt.Sprintf("[%s]", name)
|
||||
}
|
||||
return fmt.Sprintf("[%s].[%s]", schema, name)
|
||||
}
|
||||
|
||||
// WriteDatabase writes the entire database schema as SQL
|
||||
func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
// Check if we should execute SQL directly on a database
|
||||
if connString, ok := w.options.Metadata["connection_string"].(string); ok && connString != "" {
|
||||
return w.executeDatabaseSQL(db, connString)
|
||||
}
|
||||
|
||||
var writer io.Writer
|
||||
var file *os.File
|
||||
var err error
|
||||
|
||||
// Use existing writer if already set (for testing)
|
||||
if w.writer != nil {
|
||||
writer = w.writer
|
||||
} else if w.options.OutputPath != "" {
|
||||
// Determine output destination
|
||||
file, err = os.Create(w.options.OutputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
writer = file
|
||||
} else {
|
||||
writer = os.Stdout
|
||||
}
|
||||
|
||||
w.writer = writer
|
||||
|
||||
// Write header comment
|
||||
fmt.Fprintf(w.writer, "-- MSSQL Database Schema\n")
|
||||
fmt.Fprintf(w.writer, "-- Database: %s\n", db.Name)
|
||||
fmt.Fprintf(w.writer, "-- Generated by RelSpec\n\n")
|
||||
|
||||
// Process each schema in the database
|
||||
for _, schema := range db.Schemas {
|
||||
if err := w.WriteSchema(schema); err != nil {
|
||||
return fmt.Errorf("failed to write schema %s: %w", schema.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteSchema writes a single schema and all its tables
|
||||
func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||
if w.writer == nil {
|
||||
w.writer = os.Stdout
|
||||
}
|
||||
|
||||
// Phase 1: Create schema (skip dbo schema and when flattening)
|
||||
if schema.Name != "dbo" && !w.options.FlattenSchema {
|
||||
fmt.Fprintf(w.writer, "-- Schema: %s\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, "CREATE SCHEMA [%s];\n\n", schema.Name)
|
||||
}
|
||||
|
||||
// Phase 2: Create tables with columns
|
||||
fmt.Fprintf(w.writer, "-- Tables for schema: %s\n", schema.Name)
|
||||
for _, table := range schema.Tables {
|
||||
if err := w.writeCreateTable(schema, table); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Primary keys
|
||||
fmt.Fprintf(w.writer, "-- Primary keys for schema: %s\n", schema.Name)
|
||||
for _, table := range schema.Tables {
|
||||
if err := w.writePrimaryKey(schema, table); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: Indexes
|
||||
fmt.Fprintf(w.writer, "-- Indexes for schema: %s\n", schema.Name)
|
||||
for _, table := range schema.Tables {
|
||||
if err := w.writeIndexes(schema, table); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: Unique constraints
|
||||
fmt.Fprintf(w.writer, "-- Unique constraints for schema: %s\n", schema.Name)
|
||||
for _, table := range schema.Tables {
|
||||
if err := w.writeUniqueConstraints(schema, table); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 6: Check constraints
|
||||
fmt.Fprintf(w.writer, "-- Check constraints for schema: %s\n", schema.Name)
|
||||
for _, table := range schema.Tables {
|
||||
if err := w.writeCheckConstraints(schema, table); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 7: Foreign keys
|
||||
fmt.Fprintf(w.writer, "-- Foreign keys for schema: %s\n", schema.Name)
|
||||
for _, table := range schema.Tables {
|
||||
if err := w.writeForeignKeys(schema, table); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 8: Comments
|
||||
fmt.Fprintf(w.writer, "-- Comments for schema: %s\n", schema.Name)
|
||||
for _, table := range schema.Tables {
|
||||
if err := w.writeComments(schema, table); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteTable writes a single table with all its elements
|
||||
func (w *Writer) WriteTable(table *models.Table) error {
|
||||
if w.writer == nil {
|
||||
w.writer = os.Stdout
|
||||
}
|
||||
|
||||
// Create a temporary schema with just this table
|
||||
schema := models.InitSchema(table.Schema)
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
|
||||
return w.WriteSchema(schema)
|
||||
}
|
||||
|
||||
// writeCreateTable generates CREATE TABLE statement
|
||||
func (w *Writer) writeCreateTable(schema *models.Schema, table *models.Table) error {
|
||||
fmt.Fprintf(w.writer, "CREATE TABLE %s (\n", w.qualTable(schema.Name, table.Name))
|
||||
|
||||
// Sort columns by sequence
|
||||
columns := getSortedColumns(table.Columns)
|
||||
columnDefs := make([]string, 0, len(columns))
|
||||
|
||||
for _, col := range columns {
|
||||
def := w.generateColumnDefinition(col)
|
||||
columnDefs = append(columnDefs, " "+def)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w.writer, "%s\n", strings.Join(columnDefs, ",\n"))
|
||||
fmt.Fprintf(w.writer, ");\n\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateColumnDefinition generates MSSQL column definition
|
||||
func (w *Writer) generateColumnDefinition(col *models.Column) string {
|
||||
parts := []string{fmt.Sprintf("[%s]", col.Name)}
|
||||
|
||||
// Type with length/precision
|
||||
baseType := mssql.ConvertCanonicalToMSSQL(col.Type)
|
||||
typeStr := baseType
|
||||
|
||||
// Handle specific type parameters for MSSQL
|
||||
if col.Length > 0 && col.Precision == 0 {
|
||||
// String types with length - override the default length from baseType
|
||||
if strings.HasPrefix(baseType, "NVARCHAR") || strings.HasPrefix(baseType, "VARCHAR") ||
|
||||
strings.HasPrefix(baseType, "CHAR") || strings.HasPrefix(baseType, "NCHAR") {
|
||||
if col.Length > 0 && col.Length < 8000 {
|
||||
// Extract base type without length specification
|
||||
baseName := strings.Split(baseType, "(")[0]
|
||||
typeStr = fmt.Sprintf("%s(%d)", baseName, col.Length)
|
||||
}
|
||||
}
|
||||
} else if col.Precision > 0 {
|
||||
// Numeric types with precision/scale
|
||||
baseName := strings.Split(baseType, "(")[0]
|
||||
if col.Scale > 0 {
|
||||
typeStr = fmt.Sprintf("%s(%d,%d)", baseName, col.Precision, col.Scale)
|
||||
} else {
|
||||
typeStr = fmt.Sprintf("%s(%d)", baseName, col.Precision)
|
||||
}
|
||||
}
|
||||
|
||||
parts = append(parts, typeStr)
|
||||
|
||||
// IDENTITY for auto-increment
|
||||
if col.AutoIncrement {
|
||||
parts = append(parts, "IDENTITY(1,1)")
|
||||
}
|
||||
|
||||
// NOT NULL
|
||||
if col.NotNull {
|
||||
parts = append(parts, "NOT NULL")
|
||||
}
|
||||
|
||||
// DEFAULT
|
||||
if col.Default != nil {
|
||||
switch v := col.Default.(type) {
|
||||
case string:
|
||||
cleanDefault := stripBackticks(v)
|
||||
if strings.HasPrefix(strings.ToUpper(cleanDefault), "GETDATE") ||
|
||||
strings.HasPrefix(strings.ToUpper(cleanDefault), "CURRENT_") {
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT %s", cleanDefault))
|
||||
} else if cleanDefault == "true" || cleanDefault == "false" {
|
||||
if cleanDefault == "true" {
|
||||
parts = append(parts, "DEFAULT 1")
|
||||
} else {
|
||||
parts = append(parts, "DEFAULT 0")
|
||||
}
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT '%s'", escapeQuote(cleanDefault)))
|
||||
}
|
||||
case bool:
|
||||
if v {
|
||||
parts = append(parts, "DEFAULT 1")
|
||||
} else {
|
||||
parts = append(parts, "DEFAULT 0")
|
||||
}
|
||||
case int, int64:
|
||||
parts = append(parts, fmt.Sprintf("DEFAULT %v", v))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// writePrimaryKey generates ALTER TABLE statement for primary key
|
||||
func (w *Writer) writePrimaryKey(schema *models.Schema, table *models.Table) error {
|
||||
// Find primary key constraint
|
||||
var pkConstraint *models.Constraint
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint.Type == models.PrimaryKeyConstraint {
|
||||
pkConstraint = constraint
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var columnNames []string
|
||||
pkName := fmt.Sprintf("PK_%s_%s", schema.Name, table.Name)
|
||||
|
||||
if pkConstraint != nil {
|
||||
pkName = pkConstraint.Name
|
||||
columnNames = make([]string, 0, len(pkConstraint.Columns))
|
||||
for _, colName := range pkConstraint.Columns {
|
||||
columnNames = append(columnNames, fmt.Sprintf("[%s]", colName))
|
||||
}
|
||||
} else {
|
||||
// Check for columns with IsPrimaryKey = true
|
||||
for _, col := range table.Columns {
|
||||
if col.IsPrimaryKey {
|
||||
columnNames = append(columnNames, fmt.Sprintf("[%s]", col.Name))
|
||||
}
|
||||
}
|
||||
sort.Strings(columnNames)
|
||||
}
|
||||
|
||||
if len(columnNames) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(w.writer, "ALTER TABLE %s ADD CONSTRAINT [%s] PRIMARY KEY (%s);\n\n",
|
||||
w.qualTable(schema.Name, table.Name), pkName, strings.Join(columnNames, ", "))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeIndexes generates CREATE INDEX statements
|
||||
func (w *Writer) writeIndexes(schema *models.Schema, table *models.Table) error {
|
||||
// Sort indexes by name
|
||||
indexNames := make([]string, 0, len(table.Indexes))
|
||||
for name := range table.Indexes {
|
||||
indexNames = append(indexNames, name)
|
||||
}
|
||||
sort.Strings(indexNames)
|
||||
|
||||
for _, name := range indexNames {
|
||||
index := table.Indexes[name]
|
||||
|
||||
// Skip if it's a primary key index
|
||||
if strings.HasPrefix(strings.ToLower(index.Name), "pk_") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build column list
|
||||
columnExprs := make([]string, 0, len(index.Columns))
|
||||
for _, colName := range index.Columns {
|
||||
columnExprs = append(columnExprs, fmt.Sprintf("[%s]", colName))
|
||||
}
|
||||
|
||||
if len(columnExprs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
unique := ""
|
||||
if index.Unique {
|
||||
unique = "UNIQUE "
|
||||
}
|
||||
|
||||
fmt.Fprintf(w.writer, "CREATE %sINDEX [%s] ON %s (%s);\n\n",
|
||||
unique, index.Name, w.qualTable(schema.Name, table.Name), strings.Join(columnExprs, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeUniqueConstraints generates ALTER TABLE statements for unique constraints
|
||||
func (w *Writer) writeUniqueConstraints(schema *models.Schema, table *models.Table) error {
|
||||
// Sort constraints by name
|
||||
constraintNames := make([]string, 0)
|
||||
for name, constraint := range table.Constraints {
|
||||
if constraint.Type == models.UniqueConstraint {
|
||||
constraintNames = append(constraintNames, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(constraintNames)
|
||||
|
||||
for _, name := range constraintNames {
|
||||
constraint := table.Constraints[name]
|
||||
|
||||
// Build column list
|
||||
columnExprs := make([]string, 0, len(constraint.Columns))
|
||||
for _, colName := range constraint.Columns {
|
||||
columnExprs = append(columnExprs, fmt.Sprintf("[%s]", colName))
|
||||
}
|
||||
|
||||
if len(columnExprs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(w.writer, "ALTER TABLE %s ADD CONSTRAINT [%s] UNIQUE (%s);\n\n",
|
||||
w.qualTable(schema.Name, table.Name), constraint.Name, strings.Join(columnExprs, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeCheckConstraints generates ALTER TABLE statements for check constraints
|
||||
func (w *Writer) writeCheckConstraints(schema *models.Schema, table *models.Table) error {
|
||||
// Sort constraints by name
|
||||
constraintNames := make([]string, 0)
|
||||
for name, constraint := range table.Constraints {
|
||||
if constraint.Type == models.CheckConstraint {
|
||||
constraintNames = append(constraintNames, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(constraintNames)
|
||||
|
||||
for _, name := range constraintNames {
|
||||
constraint := table.Constraints[name]
|
||||
|
||||
if constraint.Expression == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(w.writer, "ALTER TABLE %s ADD CONSTRAINT [%s] CHECK (%s);\n\n",
|
||||
w.qualTable(schema.Name, table.Name), constraint.Name, constraint.Expression)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeForeignKeys generates ALTER TABLE statements for foreign keys
|
||||
func (w *Writer) writeForeignKeys(schema *models.Schema, table *models.Table) error {
|
||||
// Process foreign key constraints
|
||||
constraintNames := make([]string, 0)
|
||||
for name, constraint := range table.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
constraintNames = append(constraintNames, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(constraintNames)
|
||||
|
||||
for _, name := range constraintNames {
|
||||
constraint := table.Constraints[name]
|
||||
|
||||
// Build column lists
|
||||
sourceColumns := make([]string, 0, len(constraint.Columns))
|
||||
for _, colName := range constraint.Columns {
|
||||
sourceColumns = append(sourceColumns, fmt.Sprintf("[%s]", colName))
|
||||
}
|
||||
|
||||
targetColumns := make([]string, 0, len(constraint.ReferencedColumns))
|
||||
for _, colName := range constraint.ReferencedColumns {
|
||||
targetColumns = append(targetColumns, fmt.Sprintf("[%s]", colName))
|
||||
}
|
||||
|
||||
if len(sourceColumns) == 0 || len(targetColumns) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
refSchema := constraint.ReferencedSchema
|
||||
if refSchema == "" {
|
||||
refSchema = schema.Name
|
||||
}
|
||||
|
||||
onDelete := "NO ACTION"
|
||||
if constraint.OnDelete != "" {
|
||||
onDelete = strings.ToUpper(constraint.OnDelete)
|
||||
}
|
||||
|
||||
onUpdate := "NO ACTION"
|
||||
if constraint.OnUpdate != "" {
|
||||
onUpdate = strings.ToUpper(constraint.OnUpdate)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w.writer, "ALTER TABLE %s ADD CONSTRAINT [%s] FOREIGN KEY (%s)\n",
|
||||
w.qualTable(schema.Name, table.Name), constraint.Name, strings.Join(sourceColumns, ", "))
|
||||
fmt.Fprintf(w.writer, " REFERENCES %s (%s)\n",
|
||||
w.qualTable(refSchema, constraint.ReferencedTable), strings.Join(targetColumns, ", "))
|
||||
fmt.Fprintf(w.writer, " ON DELETE %s ON UPDATE %s;\n\n",
|
||||
onDelete, onUpdate)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeComments generates EXEC sp_addextendedproperty statements for table and column descriptions
|
||||
func (w *Writer) writeComments(schema *models.Schema, table *models.Table) error {
|
||||
// Table comment
|
||||
if table.Description != "" {
|
||||
fmt.Fprintf(w.writer, "EXEC sp_addextendedproperty\n")
|
||||
fmt.Fprintf(w.writer, " @name = 'MS_Description',\n")
|
||||
fmt.Fprintf(w.writer, " @value = '%s',\n", escapeQuote(table.Description))
|
||||
fmt.Fprintf(w.writer, " @level0type = 'SCHEMA', @level0name = '%s',\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, " @level1type = 'TABLE', @level1name = '%s';\n\n", table.Name)
|
||||
}
|
||||
|
||||
// Column comments
|
||||
for _, col := range getSortedColumns(table.Columns) {
|
||||
if col.Description != "" {
|
||||
fmt.Fprintf(w.writer, "EXEC sp_addextendedproperty\n")
|
||||
fmt.Fprintf(w.writer, " @name = 'MS_Description',\n")
|
||||
fmt.Fprintf(w.writer, " @value = '%s',\n", escapeQuote(col.Description))
|
||||
fmt.Fprintf(w.writer, " @level0type = 'SCHEMA', @level0name = '%s',\n", schema.Name)
|
||||
fmt.Fprintf(w.writer, " @level1type = 'TABLE', @level1name = '%s',\n", table.Name)
|
||||
fmt.Fprintf(w.writer, " @level2type = 'COLUMN', @level2name = '%s';\n\n", col.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDatabaseSQL executes SQL statements directly on an MSSQL database
|
||||
func (w *Writer) executeDatabaseSQL(db *models.Database, connString string) error {
|
||||
// Generate SQL statements
|
||||
statements := []string{}
|
||||
statements = append(statements, "-- MSSQL Database Schema")
|
||||
statements = append(statements, fmt.Sprintf("-- Database: %s", db.Name))
|
||||
statements = append(statements, "-- Generated by RelSpec")
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
if err := w.generateSchemaStatements(schema, &statements); err != nil {
|
||||
return fmt.Errorf("failed to generate statements for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
dbConn, err := sql.Open("mssql", connString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
defer dbConn.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err = dbConn.PingContext(ctx); err != nil {
|
||||
return fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
// Execute statements
|
||||
executedCount := 0
|
||||
for i, stmt := range statements {
|
||||
stmtTrimmed := strings.TrimSpace(stmt)
|
||||
|
||||
// Skip comments and empty statements
|
||||
if strings.HasPrefix(stmtTrimmed, "--") || stmtTrimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Executing statement %d/%d...\n", i+1, len(statements))
|
||||
|
||||
_, execErr := dbConn.ExecContext(ctx, stmt)
|
||||
if execErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "⚠ Warning: Statement failed: %v\n", execErr)
|
||||
continue
|
||||
}
|
||||
|
||||
executedCount++
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "✓ Successfully executed %d statements\n", executedCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateSchemaStatements generates SQL statements for a schema
|
||||
func (w *Writer) generateSchemaStatements(schema *models.Schema, statements *[]string) error {
|
||||
// Phase 1: Create schema
|
||||
if schema.Name != "dbo" && !w.options.FlattenSchema {
|
||||
*statements = append(*statements, fmt.Sprintf("-- Schema: %s", schema.Name))
|
||||
*statements = append(*statements, fmt.Sprintf("CREATE SCHEMA [%s];", schema.Name))
|
||||
}
|
||||
|
||||
// Phase 2: Create tables
|
||||
*statements = append(*statements, fmt.Sprintf("-- Tables for schema: %s", schema.Name))
|
||||
for _, table := range schema.Tables {
|
||||
createTableSQL := fmt.Sprintf("CREATE TABLE %s (", w.qualTable(schema.Name, table.Name))
|
||||
columnDefs := make([]string, 0)
|
||||
|
||||
columns := getSortedColumns(table.Columns)
|
||||
for _, col := range columns {
|
||||
def := w.generateColumnDefinition(col)
|
||||
columnDefs = append(columnDefs, " "+def)
|
||||
}
|
||||
|
||||
createTableSQL += "\n" + strings.Join(columnDefs, ",\n") + "\n)"
|
||||
*statements = append(*statements, createTableSQL)
|
||||
}
|
||||
|
||||
// Phase 3-7: Constraints and indexes will be added by WriteSchema logic
|
||||
// For now, just create tables
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// getSortedColumns returns columns sorted by sequence
|
||||
func getSortedColumns(columns map[string]*models.Column) []*models.Column {
|
||||
names := make([]string, 0, len(columns))
|
||||
for name := range columns {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
sorted := make([]*models.Column, 0, len(columns))
|
||||
for _, name := range names {
|
||||
sorted = append(sorted, columns[name])
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
// escapeQuote escapes single quotes in strings for SQL
|
||||
func escapeQuote(s string) string {
|
||||
return strings.ReplaceAll(s, "'", "''")
|
||||
}
|
||||
|
||||
// stripBackticks removes backticks from SQL expressions
|
||||
func stripBackticks(s string) string {
|
||||
return strings.ReplaceAll(s, "`", "")
|
||||
}
|
||||
205
pkg/writers/mssql/writer_test.go
Normal file
205
pkg/writers/mssql/writer_test.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestGenerateColumnDefinition tests column definition generation
|
||||
func TestGenerateColumnDefinition(t *testing.T) {
|
||||
writer := NewWriter(&writers.WriterOptions{})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
column *models.Column
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "INT NOT NULL",
|
||||
column: &models.Column{
|
||||
Name: "id",
|
||||
Type: "int",
|
||||
NotNull: true,
|
||||
Sequence: 1,
|
||||
},
|
||||
expected: "[id] INT NOT NULL",
|
||||
},
|
||||
{
|
||||
name: "VARCHAR with length",
|
||||
column: &models.Column{
|
||||
Name: "name",
|
||||
Type: "string",
|
||||
Length: 100,
|
||||
NotNull: true,
|
||||
Sequence: 2,
|
||||
},
|
||||
expected: "[name] NVARCHAR(100) NOT NULL",
|
||||
},
|
||||
{
|
||||
name: "DATETIME2 with default",
|
||||
column: &models.Column{
|
||||
Name: "created_at",
|
||||
Type: "timestamp",
|
||||
NotNull: true,
|
||||
Default: "GETDATE()",
|
||||
Sequence: 3,
|
||||
},
|
||||
expected: "[created_at] DATETIME2 NOT NULL DEFAULT GETDATE()",
|
||||
},
|
||||
{
|
||||
name: "IDENTITY column",
|
||||
column: &models.Column{
|
||||
Name: "id",
|
||||
Type: "int",
|
||||
AutoIncrement: true,
|
||||
NotNull: true,
|
||||
Sequence: 1,
|
||||
},
|
||||
expected: "[id] INT IDENTITY(1,1) NOT NULL",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := writer.generateColumnDefinition(tt.column)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteCreateTable tests CREATE TABLE statement generation
|
||||
func TestWriteCreateTable(t *testing.T) {
|
||||
writer := NewWriter(&writers.WriterOptions{})
|
||||
|
||||
// Create a test schema with a table
|
||||
schema := models.InitSchema("dbo")
|
||||
table := models.InitTable("users", "dbo")
|
||||
|
||||
col1 := models.InitColumn("id", "users", "dbo")
|
||||
col1.Type = "int"
|
||||
col1.AutoIncrement = true
|
||||
col1.NotNull = true
|
||||
col1.Sequence = 1
|
||||
|
||||
col2 := models.InitColumn("email", "users", "dbo")
|
||||
col2.Type = "string"
|
||||
col2.Length = 255
|
||||
col2.NotNull = true
|
||||
col2.Sequence = 2
|
||||
|
||||
table.Columns["id"] = col1
|
||||
table.Columns["email"] = col2
|
||||
|
||||
// Write to buffer
|
||||
buf := &bytes.Buffer{}
|
||||
writer.writer = buf
|
||||
|
||||
err := writer.writeCreateTable(schema, table)
|
||||
assert.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "CREATE TABLE [dbo].[users]")
|
||||
assert.Contains(t, output, "[id] INT IDENTITY(1,1) NOT NULL")
|
||||
assert.Contains(t, output, "[email] NVARCHAR(255) NOT NULL")
|
||||
}
|
||||
|
||||
// TestWritePrimaryKey tests PRIMARY KEY constraint generation
|
||||
func TestWritePrimaryKey(t *testing.T) {
|
||||
writer := NewWriter(&writers.WriterOptions{})
|
||||
|
||||
schema := models.InitSchema("dbo")
|
||||
table := models.InitTable("users", "dbo")
|
||||
|
||||
// Add primary key constraint
|
||||
pk := models.InitConstraint("PK_users_id", models.PrimaryKeyConstraint)
|
||||
pk.Columns = []string{"id"}
|
||||
table.Constraints[pk.Name] = pk
|
||||
|
||||
// Add column
|
||||
col := models.InitColumn("id", "users", "dbo")
|
||||
col.Type = "int"
|
||||
col.Sequence = 1
|
||||
table.Columns["id"] = col
|
||||
|
||||
// Write to buffer
|
||||
buf := &bytes.Buffer{}
|
||||
writer.writer = buf
|
||||
|
||||
err := writer.writePrimaryKey(schema, table)
|
||||
assert.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "ALTER TABLE [dbo].[users]")
|
||||
assert.Contains(t, output, "PRIMARY KEY")
|
||||
assert.Contains(t, output, "[id]")
|
||||
}
|
||||
|
||||
// TestWriteForeignKey tests FOREIGN KEY constraint generation
|
||||
func TestWriteForeignKey(t *testing.T) {
|
||||
writer := NewWriter(&writers.WriterOptions{})
|
||||
|
||||
schema := models.InitSchema("dbo")
|
||||
table := models.InitTable("orders", "dbo")
|
||||
|
||||
// Add foreign key constraint
|
||||
fk := models.InitConstraint("FK_orders_users", models.ForeignKeyConstraint)
|
||||
fk.Columns = []string{"user_id"}
|
||||
fk.ReferencedSchema = "dbo"
|
||||
fk.ReferencedTable = "users"
|
||||
fk.ReferencedColumns = []string{"id"}
|
||||
fk.OnDelete = "CASCADE"
|
||||
fk.OnUpdate = "NO ACTION"
|
||||
table.Constraints[fk.Name] = fk
|
||||
|
||||
// Add column
|
||||
col := models.InitColumn("user_id", "orders", "dbo")
|
||||
col.Type = "int"
|
||||
col.Sequence = 1
|
||||
table.Columns["user_id"] = col
|
||||
|
||||
// Write to buffer
|
||||
buf := &bytes.Buffer{}
|
||||
writer.writer = buf
|
||||
|
||||
err := writer.writeForeignKeys(schema, table)
|
||||
assert.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "ALTER TABLE [dbo].[orders]")
|
||||
assert.Contains(t, output, "FK_orders_users")
|
||||
assert.Contains(t, output, "FOREIGN KEY")
|
||||
assert.Contains(t, output, "CASCADE")
|
||||
assert.Contains(t, output, "NO ACTION")
|
||||
}
|
||||
|
||||
// TestWriteComments tests extended property generation for comments
|
||||
func TestWriteComments(t *testing.T) {
|
||||
writer := NewWriter(&writers.WriterOptions{})
|
||||
|
||||
schema := models.InitSchema("dbo")
|
||||
table := models.InitTable("users", "dbo")
|
||||
table.Description = "User accounts table"
|
||||
|
||||
col := models.InitColumn("id", "users", "dbo")
|
||||
col.Type = "int"
|
||||
col.Description = "Primary key"
|
||||
col.Sequence = 1
|
||||
table.Columns["id"] = col
|
||||
|
||||
// Write to buffer
|
||||
buf := &bytes.Buffer{}
|
||||
writer.writer = buf
|
||||
|
||||
err := writer.writeComments(schema, table)
|
||||
assert.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "sp_addextendedproperty")
|
||||
assert.Contains(t, output, "MS_Description")
|
||||
assert.Contains(t, output, "User accounts table")
|
||||
assert.Contains(t, output, "Primary key")
|
||||
}
|
||||
Reference in New Issue
Block a user