feat: ✨ Added Sqlite reader
This commit is contained in:
75
pkg/readers/sqlite/README.md
Normal file
75
pkg/readers/sqlite/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# SQLite Reader
|
||||
|
||||
Reads database schema from SQLite database files.
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqlite"
|
||||
)
|
||||
|
||||
// Using file path
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: "path/to/database.db",
|
||||
}
|
||||
|
||||
reader := sqlite.NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
|
||||
// Or using connection string
|
||||
options := &readers.ReaderOptions{
|
||||
ConnectionString: "path/to/database.db",
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Reads tables with columns and data types
|
||||
- Reads views with definitions
|
||||
- Reads primary keys
|
||||
- Reads foreign keys with CASCADE actions
|
||||
- Reads indexes (non-auto-generated)
|
||||
- Maps SQLite types to canonical types
|
||||
- Derives relationships from foreign keys
|
||||
|
||||
## SQLite Specifics
|
||||
|
||||
- SQLite doesn't support schemas, creates single "main" schema
|
||||
- Uses pure Go driver (modernc.org/sqlite) - no CGo required
|
||||
- Supports both file path and connection string
|
||||
- Auto-increment detection for INTEGER PRIMARY KEY columns
|
||||
- Foreign keys require `PRAGMA foreign_keys = ON` to be set
|
||||
|
||||
## Example Schema
|
||||
|
||||
```sql
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
email VARCHAR(100) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
## Type Mappings
|
||||
|
||||
| SQLite Type | Canonical Type |
|
||||
|-------------|---------------|
|
||||
| INTEGER, INT | int |
|
||||
| BIGINT | int64 |
|
||||
| REAL, DOUBLE | float64 |
|
||||
| TEXT, VARCHAR | string |
|
||||
| BLOB | bytea |
|
||||
| BOOLEAN | bool |
|
||||
| DATE | date |
|
||||
| DATETIME, TIMESTAMP | timestamp |
|
||||
306
pkg/readers/sqlite/queries.go
Normal file
306
pkg/readers/sqlite/queries.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// queryTables retrieves all tables from the SQLite database
|
||||
func (r *Reader) queryTables() ([]*models.Table, error) {
|
||||
query := `
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tables := make([]*models.Table, 0)
|
||||
for rows.Next() {
|
||||
var tableName string
|
||||
|
||||
if err := rows.Scan(&tableName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
table := models.InitTable(tableName, "main")
|
||||
tables = append(tables, table)
|
||||
}
|
||||
|
||||
return tables, rows.Err()
|
||||
}
|
||||
|
||||
// queryViews retrieves all views from the SQLite database
|
||||
func (r *Reader) queryViews() ([]*models.View, error) {
|
||||
query := `
|
||||
SELECT name, sql
|
||||
FROM sqlite_master
|
||||
WHERE type = 'view'
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
views := make([]*models.View, 0)
|
||||
for rows.Next() {
|
||||
var viewName string
|
||||
var sql *string
|
||||
|
||||
if err := rows.Scan(&viewName, &sql); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
view := models.InitView(viewName, "main")
|
||||
if sql != nil {
|
||||
view.Definition = *sql
|
||||
}
|
||||
|
||||
views = append(views, view)
|
||||
}
|
||||
|
||||
return views, rows.Err()
|
||||
}
|
||||
|
||||
// queryColumns retrieves all columns for a given table or view
|
||||
func (r *Reader) queryColumns(tableName string) (map[string]*models.Column, error) {
|
||||
query := fmt.Sprintf("PRAGMA table_info(%s)", tableName)
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns := make(map[string]*models.Column)
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, dataType string
|
||||
var notNull, pk int
|
||||
var defaultValue *string
|
||||
|
||||
if err := rows.Scan(&cid, &name, &dataType, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
column := models.InitColumn(name, tableName, "main")
|
||||
column.Type = r.mapDataType(strings.ToUpper(dataType))
|
||||
column.NotNull = (notNull == 1)
|
||||
column.IsPrimaryKey = (pk > 0)
|
||||
column.Sequence = uint(cid + 1)
|
||||
|
||||
if defaultValue != nil {
|
||||
column.Default = *defaultValue
|
||||
}
|
||||
|
||||
// Check for autoincrement (SQLite uses INTEGER PRIMARY KEY AUTOINCREMENT)
|
||||
if pk > 0 && strings.ToUpper(dataType) == "INTEGER" {
|
||||
column.AutoIncrement = r.isAutoIncrement(tableName, name)
|
||||
}
|
||||
|
||||
columns[name] = column
|
||||
}
|
||||
|
||||
return columns, rows.Err()
|
||||
}
|
||||
|
||||
// isAutoIncrement checks if a column is autoincrement
|
||||
func (r *Reader) isAutoIncrement(tableName, columnName string) bool {
|
||||
// Check sqlite_sequence table or parse CREATE TABLE statement
|
||||
query := `
|
||||
SELECT sql
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = ?
|
||||
`
|
||||
|
||||
var sql string
|
||||
err := r.db.QueryRowContext(r.ctx, query, tableName).Scan(&sql)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the SQL contains AUTOINCREMENT for this column
|
||||
return strings.Contains(strings.ToUpper(sql), strings.ToUpper(columnName)+" INTEGER PRIMARY KEY AUTOINCREMENT") ||
|
||||
strings.Contains(strings.ToUpper(sql), strings.ToUpper(columnName)+" INTEGER AUTOINCREMENT")
|
||||
}
|
||||
|
||||
// queryPrimaryKey retrieves the primary key constraint for a table
|
||||
func (r *Reader) queryPrimaryKey(tableName string) (*models.Constraint, error) {
|
||||
query := fmt.Sprintf("PRAGMA table_info(%s)", tableName)
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var pkColumns []string
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, dataType string
|
||||
var notNull, pk int
|
||||
var defaultValue *string
|
||||
|
||||
if err := rows.Scan(&cid, &name, &dataType, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if pk > 0 {
|
||||
pkColumns = append(pkColumns, name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(pkColumns) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Create primary key constraint
|
||||
constraintName := fmt.Sprintf("%s_pkey", tableName)
|
||||
constraint := models.InitConstraint(constraintName, models.PrimaryKeyConstraint)
|
||||
constraint.Schema = "main"
|
||||
constraint.Table = tableName
|
||||
constraint.Columns = pkColumns
|
||||
|
||||
return constraint, rows.Err()
|
||||
}
|
||||
|
||||
// queryForeignKeys retrieves all foreign key constraints for a table
|
||||
func (r *Reader) queryForeignKeys(tableName string) ([]*models.Constraint, error) {
|
||||
query := fmt.Sprintf("PRAGMA foreign_key_list(%s)", tableName)
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Group foreign keys by id (since composite FKs have multiple rows)
|
||||
fkMap := make(map[int]*models.Constraint)
|
||||
|
||||
for rows.Next() {
|
||||
var id, seq int
|
||||
var referencedTable, fromColumn, toColumn string
|
||||
var onUpdate, onDelete, match string
|
||||
|
||||
if err := rows.Scan(&id, &seq, &referencedTable, &fromColumn, &toColumn, &onUpdate, &onDelete, &match); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, exists := fkMap[id]; !exists {
|
||||
constraintName := fmt.Sprintf("%s_%s_fkey", tableName, referencedTable)
|
||||
if id > 0 {
|
||||
constraintName = fmt.Sprintf("%s_%s_fkey_%d", tableName, referencedTable, id)
|
||||
}
|
||||
|
||||
constraint := models.InitConstraint(constraintName, models.ForeignKeyConstraint)
|
||||
constraint.Schema = "main"
|
||||
constraint.Table = tableName
|
||||
constraint.ReferencedSchema = "main"
|
||||
constraint.ReferencedTable = referencedTable
|
||||
constraint.OnUpdate = onUpdate
|
||||
constraint.OnDelete = onDelete
|
||||
constraint.Columns = []string{}
|
||||
constraint.ReferencedColumns = []string{}
|
||||
|
||||
fkMap[id] = constraint
|
||||
}
|
||||
|
||||
// Add column to the constraint
|
||||
fkMap[id].Columns = append(fkMap[id].Columns, fromColumn)
|
||||
fkMap[id].ReferencedColumns = append(fkMap[id].ReferencedColumns, toColumn)
|
||||
}
|
||||
|
||||
// Convert map to slice
|
||||
foreignKeys := make([]*models.Constraint, 0, len(fkMap))
|
||||
for _, fk := range fkMap {
|
||||
foreignKeys = append(foreignKeys, fk)
|
||||
}
|
||||
|
||||
return foreignKeys, rows.Err()
|
||||
}
|
||||
|
||||
// queryIndexes retrieves all indexes for a table
|
||||
func (r *Reader) queryIndexes(tableName string) ([]*models.Index, error) {
|
||||
query := fmt.Sprintf("PRAGMA index_list(%s)", tableName)
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
indexes := make([]*models.Index, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var seq int
|
||||
var name string
|
||||
var unique int
|
||||
var origin string
|
||||
var partial int
|
||||
|
||||
if err := rows.Scan(&seq, &name, &unique, &origin, &partial); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Skip auto-generated indexes (origin = 'pk' for primary keys, etc.)
|
||||
// origin: c = CREATE INDEX, u = UNIQUE constraint, pk = PRIMARY KEY
|
||||
if origin == "pk" || origin == "u" {
|
||||
continue
|
||||
}
|
||||
|
||||
index := models.InitIndex(name, tableName, "main")
|
||||
index.Unique = (unique == 1)
|
||||
|
||||
// Get index columns
|
||||
columns, err := r.queryIndexColumns(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index.Columns = columns
|
||||
|
||||
indexes = append(indexes, index)
|
||||
}
|
||||
|
||||
return indexes, rows.Err()
|
||||
}
|
||||
|
||||
// queryIndexColumns retrieves the columns for a specific index
|
||||
func (r *Reader) queryIndexColumns(indexName string) ([]string, error) {
|
||||
query := fmt.Sprintf("PRAGMA index_info(%s)", indexName)
|
||||
|
||||
rows, err := r.db.QueryContext(r.ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns := make([]string, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var seqno, cid int
|
||||
var name *string
|
||||
|
||||
if err := rows.Scan(&seqno, &cid, &name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if name != nil {
|
||||
columns = append(columns, *name)
|
||||
}
|
||||
}
|
||||
|
||||
return columns, rows.Err()
|
||||
}
|
||||
261
pkg/readers/sqlite/reader.go
Normal file
261
pkg/readers/sqlite/reader.go
Normal file
@@ -0,0 +1,261 @@
|
||||
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
|
||||
}
|
||||
334
pkg/readers/sqlite/reader_test.go
Normal file
334
pkg/readers/sqlite/reader_test.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
|
||||
// setupTestDatabase creates a temporary SQLite database with test data
|
||||
func setupTestDatabase(t *testing.T) string {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
// Create test schema
|
||||
schema := `
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
email VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT,
|
||||
published BOOLEAN DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
comment TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_posts_user_id ON posts(user_id);
|
||||
CREATE INDEX idx_comments_post_id ON comments(post_id);
|
||||
CREATE UNIQUE INDEX idx_users_email ON users(email);
|
||||
|
||||
CREATE VIEW user_post_count AS
|
||||
SELECT u.id, u.username, COUNT(p.id) as post_count
|
||||
FROM users u
|
||||
LEFT JOIN posts p ON u.id = p.user_id
|
||||
GROUP BY u.id, u.username;
|
||||
`
|
||||
|
||||
_, err = db.Exec(schema)
|
||||
require.NoError(t, err)
|
||||
|
||||
return dbPath
|
||||
}
|
||||
|
||||
func TestReader_ReadDatabase(t *testing.T) {
|
||||
dbPath := setupTestDatabase(t)
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: dbPath,
|
||||
}
|
||||
|
||||
reader := NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, db)
|
||||
|
||||
// Check database metadata
|
||||
assert.Equal(t, "test.db", db.Name)
|
||||
assert.Equal(t, models.SqlLiteDatabaseType, db.DatabaseType)
|
||||
assert.Equal(t, "sqlite", db.SourceFormat)
|
||||
assert.NotEmpty(t, db.DatabaseVersion)
|
||||
|
||||
// Check schemas (SQLite should have a single "main" schema)
|
||||
require.Len(t, db.Schemas, 1)
|
||||
schema := db.Schemas[0]
|
||||
assert.Equal(t, "main", schema.Name)
|
||||
|
||||
// Check tables
|
||||
assert.Len(t, schema.Tables, 3)
|
||||
tableNames := make([]string, len(schema.Tables))
|
||||
for i, table := range schema.Tables {
|
||||
tableNames[i] = table.Name
|
||||
}
|
||||
assert.Contains(t, tableNames, "users")
|
||||
assert.Contains(t, tableNames, "posts")
|
||||
assert.Contains(t, tableNames, "comments")
|
||||
|
||||
// Check views
|
||||
assert.Len(t, schema.Views, 1)
|
||||
assert.Equal(t, "user_post_count", schema.Views[0].Name)
|
||||
assert.NotEmpty(t, schema.Views[0].Definition)
|
||||
}
|
||||
|
||||
func TestReader_ReadTable_Users(t *testing.T) {
|
||||
dbPath := setupTestDatabase(t)
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: dbPath,
|
||||
}
|
||||
|
||||
reader := NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, db)
|
||||
|
||||
// Find users table
|
||||
var usersTable *models.Table
|
||||
for _, table := range db.Schemas[0].Tables {
|
||||
if table.Name == "users" {
|
||||
usersTable = table
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, usersTable)
|
||||
assert.Equal(t, "users", usersTable.Name)
|
||||
assert.Equal(t, "main", usersTable.Schema)
|
||||
|
||||
// Check columns
|
||||
assert.Len(t, usersTable.Columns, 4)
|
||||
|
||||
// Check id column
|
||||
idCol, exists := usersTable.Columns["id"]
|
||||
require.True(t, exists)
|
||||
assert.Equal(t, "int", idCol.Type)
|
||||
assert.True(t, idCol.IsPrimaryKey)
|
||||
assert.True(t, idCol.AutoIncrement)
|
||||
assert.True(t, idCol.NotNull)
|
||||
|
||||
// Check username column
|
||||
usernameCol, exists := usersTable.Columns["username"]
|
||||
require.True(t, exists)
|
||||
assert.Equal(t, "string", usernameCol.Type)
|
||||
assert.True(t, usernameCol.NotNull)
|
||||
assert.False(t, usernameCol.IsPrimaryKey)
|
||||
|
||||
// Check email column
|
||||
emailCol, exists := usersTable.Columns["email"]
|
||||
require.True(t, exists)
|
||||
assert.Equal(t, "string", emailCol.Type)
|
||||
assert.True(t, emailCol.NotNull)
|
||||
|
||||
// Check primary key constraint
|
||||
assert.Len(t, usersTable.Constraints, 1)
|
||||
pkConstraint, exists := usersTable.Constraints["users_pkey"]
|
||||
require.True(t, exists)
|
||||
assert.Equal(t, models.PrimaryKeyConstraint, pkConstraint.Type)
|
||||
assert.Equal(t, []string{"id"}, pkConstraint.Columns)
|
||||
|
||||
// Check indexes (should have unique index on email and username)
|
||||
assert.GreaterOrEqual(t, len(usersTable.Indexes), 1)
|
||||
}
|
||||
|
||||
func TestReader_ReadTable_Posts(t *testing.T) {
|
||||
dbPath := setupTestDatabase(t)
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: dbPath,
|
||||
}
|
||||
|
||||
reader := NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, db)
|
||||
|
||||
// Find posts table
|
||||
var postsTable *models.Table
|
||||
for _, table := range db.Schemas[0].Tables {
|
||||
if table.Name == "posts" {
|
||||
postsTable = table
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, postsTable)
|
||||
|
||||
// Check columns
|
||||
assert.Len(t, postsTable.Columns, 6)
|
||||
|
||||
// Check foreign key constraint
|
||||
hasForeignKey := false
|
||||
for _, constraint := range postsTable.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
hasForeignKey = true
|
||||
assert.Equal(t, "users", constraint.ReferencedTable)
|
||||
assert.Equal(t, "CASCADE", constraint.OnDelete)
|
||||
}
|
||||
}
|
||||
assert.True(t, hasForeignKey, "Posts table should have a foreign key constraint")
|
||||
|
||||
// Check relationships
|
||||
assert.GreaterOrEqual(t, len(postsTable.Relationships), 1)
|
||||
|
||||
// Check indexes
|
||||
hasUserIdIndex := false
|
||||
for _, index := range postsTable.Indexes {
|
||||
if index.Name == "idx_posts_user_id" {
|
||||
hasUserIdIndex = true
|
||||
assert.Contains(t, index.Columns, "user_id")
|
||||
}
|
||||
}
|
||||
assert.True(t, hasUserIdIndex, "Posts table should have idx_posts_user_id index")
|
||||
}
|
||||
|
||||
func TestReader_ReadTable_Comments(t *testing.T) {
|
||||
dbPath := setupTestDatabase(t)
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: dbPath,
|
||||
}
|
||||
|
||||
reader := NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, db)
|
||||
|
||||
// Find comments table
|
||||
var commentsTable *models.Table
|
||||
for _, table := range db.Schemas[0].Tables {
|
||||
if table.Name == "comments" {
|
||||
commentsTable = table
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, commentsTable)
|
||||
|
||||
// Check foreign key constraints (should have 2)
|
||||
fkCount := 0
|
||||
for _, constraint := range commentsTable.Constraints {
|
||||
if constraint.Type == models.ForeignKeyConstraint {
|
||||
fkCount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 2, fkCount, "Comments table should have 2 foreign key constraints")
|
||||
}
|
||||
|
||||
func TestReader_ReadSchema(t *testing.T) {
|
||||
dbPath := setupTestDatabase(t)
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: dbPath,
|
||||
}
|
||||
|
||||
reader := NewReader(options)
|
||||
schema, err := reader.ReadSchema()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, schema)
|
||||
assert.Equal(t, "main", schema.Name)
|
||||
assert.Len(t, schema.Tables, 3)
|
||||
assert.Len(t, schema.Views, 1)
|
||||
}
|
||||
|
||||
func TestReader_ReadTable(t *testing.T) {
|
||||
dbPath := setupTestDatabase(t)
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: dbPath,
|
||||
}
|
||||
|
||||
reader := NewReader(options)
|
||||
table, err := reader.ReadTable()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, table)
|
||||
assert.NotEmpty(t, table.Name)
|
||||
assert.NotEmpty(t, table.Columns)
|
||||
}
|
||||
|
||||
func TestReader_ConnectionString(t *testing.T) {
|
||||
dbPath := setupTestDatabase(t)
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
options := &readers.ReaderOptions{
|
||||
ConnectionString: dbPath,
|
||||
}
|
||||
|
||||
reader := NewReader(options)
|
||||
db, err := reader.ReadDatabase()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, db)
|
||||
assert.Len(t, db.Schemas, 1)
|
||||
}
|
||||
|
||||
func TestReader_InvalidPath(t *testing.T) {
|
||||
options := &readers.ReaderOptions{
|
||||
FilePath: "/nonexistent/path/to/database.db",
|
||||
}
|
||||
|
||||
reader := NewReader(options)
|
||||
_, err := reader.ReadDatabase()
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestReader_MissingPath(t *testing.T) {
|
||||
options := &readers.ReaderOptions{}
|
||||
|
||||
reader := NewReader(options)
|
||||
_, err := reader.ReadDatabase()
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "file path or connection string is required")
|
||||
}
|
||||
Reference in New Issue
Block a user