- Implement functionality to create, update, delete, and view relationships between tables. - Introduce new UI screens for managing relationships, including forms for adding and editing relationships. - Enhance table editor with navigation to relationship management. - Ensure relationships are displayed in a structured table format for better usability.
262 lines
6.8 KiB
Go
262 lines
6.8 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"path/filepath"
|
|
|
|
_ "modernc.org/sqlite" // SQLite driver
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
|
)
|
|
|
|
// Reader implements the readers.Reader interface for SQLite databases
|
|
type Reader struct {
|
|
options *readers.ReaderOptions
|
|
db *sql.DB
|
|
ctx context.Context
|
|
}
|
|
|
|
// NewReader creates a new SQLite reader
|
|
func NewReader(options *readers.ReaderOptions) *Reader {
|
|
return &Reader{
|
|
options: options,
|
|
ctx: context.Background(),
|
|
}
|
|
}
|
|
|
|
// ReadDatabase reads the entire database schema from SQLite
|
|
func (r *Reader) ReadDatabase() (*models.Database, error) {
|
|
// Validate file path or connection string
|
|
dbPath := r.options.FilePath
|
|
if dbPath == "" && r.options.ConnectionString != "" {
|
|
dbPath = r.options.ConnectionString
|
|
}
|
|
if dbPath == "" {
|
|
return nil, fmt.Errorf("file path or connection string is required")
|
|
}
|
|
|
|
// Connect to the database
|
|
if err := r.connect(dbPath); err != nil {
|
|
return nil, fmt.Errorf("failed to connect: %w", err)
|
|
}
|
|
defer r.close()
|
|
|
|
// Get database name from file path
|
|
dbName := filepath.Base(dbPath)
|
|
if dbName == "" {
|
|
dbName = "sqlite"
|
|
}
|
|
|
|
// Initialize database model
|
|
db := models.InitDatabase(dbName)
|
|
db.DatabaseType = models.SqlLiteDatabaseType
|
|
db.SourceFormat = "sqlite"
|
|
|
|
// Get SQLite version
|
|
var version string
|
|
err := r.db.QueryRowContext(r.ctx, "SELECT sqlite_version()").Scan(&version)
|
|
if err == nil {
|
|
db.DatabaseVersion = version
|
|
}
|
|
|
|
// SQLite doesn't have schemas, so we create a single "main" schema
|
|
schema := models.InitSchema("main")
|
|
schema.RefDatabase = db
|
|
|
|
// Query tables
|
|
tables, err := r.queryTables()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query tables: %w", err)
|
|
}
|
|
schema.Tables = tables
|
|
|
|
// Query views
|
|
views, err := r.queryViews()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query views: %w", err)
|
|
}
|
|
schema.Views = views
|
|
|
|
// Query columns for tables and views
|
|
for _, table := range schema.Tables {
|
|
columns, err := r.queryColumns(table.Name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query columns for table %s: %w", table.Name, err)
|
|
}
|
|
table.Columns = columns
|
|
table.RefSchema = schema
|
|
|
|
// Query primary key
|
|
pk, err := r.queryPrimaryKey(table.Name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query primary key for table %s: %w", table.Name, err)
|
|
}
|
|
if pk != nil {
|
|
table.Constraints[pk.Name] = pk
|
|
// Mark columns as primary key and not null
|
|
for _, colName := range pk.Columns {
|
|
if col, exists := table.Columns[colName]; exists {
|
|
col.IsPrimaryKey = true
|
|
col.NotNull = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Query foreign keys
|
|
foreignKeys, err := r.queryForeignKeys(table.Name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query foreign keys for table %s: %w", table.Name, err)
|
|
}
|
|
for _, fk := range foreignKeys {
|
|
table.Constraints[fk.Name] = fk
|
|
// Derive relationship from foreign key
|
|
r.deriveRelationship(table, fk)
|
|
}
|
|
|
|
// Query indexes
|
|
indexes, err := r.queryIndexes(table.Name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query indexes for table %s: %w", table.Name, err)
|
|
}
|
|
for _, idx := range indexes {
|
|
table.Indexes[idx.Name] = idx
|
|
}
|
|
}
|
|
|
|
// Query columns for views
|
|
for _, view := range schema.Views {
|
|
columns, err := r.queryColumns(view.Name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query columns for view %s: %w", view.Name, err)
|
|
}
|
|
view.Columns = columns
|
|
view.RefSchema = schema
|
|
}
|
|
|
|
// Add schema to database
|
|
db.Schemas = append(db.Schemas, schema)
|
|
|
|
return db, nil
|
|
}
|
|
|
|
// ReadSchema reads a single schema (returns the main schema from the database)
|
|
func (r *Reader) ReadSchema() (*models.Schema, error) {
|
|
db, err := r.ReadDatabase()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(db.Schemas) == 0 {
|
|
return nil, fmt.Errorf("no schemas found in database")
|
|
}
|
|
return db.Schemas[0], nil
|
|
}
|
|
|
|
// ReadTable reads a single table (returns the first table from the schema)
|
|
func (r *Reader) ReadTable() (*models.Table, error) {
|
|
schema, err := r.ReadSchema()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(schema.Tables) == 0 {
|
|
return nil, fmt.Errorf("no tables found in schema")
|
|
}
|
|
return schema.Tables[0], nil
|
|
}
|
|
|
|
// connect establishes a connection to the SQLite database
|
|
func (r *Reader) connect(dbPath string) error {
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.db = db
|
|
return nil
|
|
}
|
|
|
|
// close closes the database connection
|
|
func (r *Reader) close() {
|
|
if r.db != nil {
|
|
r.db.Close()
|
|
}
|
|
}
|
|
|
|
// mapDataType maps SQLite data types to canonical types
|
|
func (r *Reader) mapDataType(sqliteType string) string {
|
|
// SQLite has a flexible type system, but we map common types
|
|
typeMap := map[string]string{
|
|
"INTEGER": "int",
|
|
"INT": "int",
|
|
"TINYINT": "int8",
|
|
"SMALLINT": "int16",
|
|
"MEDIUMINT": "int",
|
|
"BIGINT": "int64",
|
|
"UNSIGNED BIG INT": "uint64",
|
|
"INT2": "int16",
|
|
"INT8": "int64",
|
|
"REAL": "float64",
|
|
"DOUBLE": "float64",
|
|
"DOUBLE PRECISION": "float64",
|
|
"FLOAT": "float32",
|
|
"NUMERIC": "decimal",
|
|
"DECIMAL": "decimal",
|
|
"BOOLEAN": "bool",
|
|
"BOOL": "bool",
|
|
"DATE": "date",
|
|
"DATETIME": "timestamp",
|
|
"TIMESTAMP": "timestamp",
|
|
"TEXT": "string",
|
|
"VARCHAR": "string",
|
|
"CHAR": "string",
|
|
"CHARACTER": "string",
|
|
"VARYING CHARACTER": "string",
|
|
"NCHAR": "string",
|
|
"NVARCHAR": "string",
|
|
"CLOB": "text",
|
|
"BLOB": "bytea",
|
|
}
|
|
|
|
// Try exact match first
|
|
if mapped, exists := typeMap[sqliteType]; exists {
|
|
return mapped
|
|
}
|
|
|
|
// Try case-insensitive match for common types
|
|
sqliteTypeUpper := sqliteType
|
|
if len(sqliteType) > 0 {
|
|
// Extract base type (e.g., "VARCHAR(255)" -> "VARCHAR")
|
|
for baseType := range typeMap {
|
|
if len(sqliteTypeUpper) >= len(baseType) && sqliteTypeUpper[:len(baseType)] == baseType {
|
|
return typeMap[baseType]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Default to string for unknown types
|
|
return "string"
|
|
}
|
|
|
|
// deriveRelationship creates a relationship from a foreign key constraint
|
|
func (r *Reader) deriveRelationship(table *models.Table, fk *models.Constraint) {
|
|
relationshipName := fmt.Sprintf("%s_to_%s", table.Name, fk.ReferencedTable)
|
|
|
|
relationship := models.InitRelationship(relationshipName, models.OneToMany)
|
|
relationship.FromTable = table.Name
|
|
relationship.FromSchema = table.Schema
|
|
relationship.ToTable = fk.ReferencedTable
|
|
relationship.ToSchema = fk.ReferencedSchema
|
|
relationship.ForeignKey = fk.Name
|
|
|
|
// Store constraint actions in properties
|
|
if fk.OnDelete != "" {
|
|
relationship.Properties["on_delete"] = fk.OnDelete
|
|
}
|
|
if fk.OnUpdate != "" {
|
|
relationship.Properties["on_update"] = fk.OnUpdate
|
|
}
|
|
|
|
table.Relationships[relationshipName] = relationship
|
|
}
|