Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae0efdc008 | ||
|
|
be08c8199f | ||
|
|
19b592820c | ||
|
|
b158a98acc |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
|
||||||
pkgname=relspec
|
pkgname=relspec
|
||||||
pkgver=1.0.68
|
pkgver=1.0.70
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs."
|
pkgdesc="RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs."
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
Name: relspec
|
Name: relspec
|
||||||
Version: 1.0.68
|
Version: 1.0.70
|
||||||
Release: 1%{?dist}
|
Release: 1%{?dist}
|
||||||
Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs.
|
Summary: RelSpec is a comprehensive database relations management tool that reads, transforms, and writes database table specifications across multiple formats and ORMs.
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -492,7 +492,12 @@ func extractTypeParts(col *models.Column) (baseType string, length, precision, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
typeName = pgsql.NormalizePGType(typeName)
|
// serial/bigserial/smallserial are sugar over an integer column plus a
|
||||||
|
// sequence default; PostgreSQL itself reports the underlying integer
|
||||||
|
// type back for such columns, so treat them as equivalent here to avoid
|
||||||
|
// spurious conflicts between a DBML "bigserial" source and a live-read
|
||||||
|
// "bigint" target (or vice versa).
|
||||||
|
typeName = pgsql.SerialUnderlyingType(typeName)
|
||||||
|
|
||||||
return typeName, length, precision, scale
|
return typeName, length, precision, scale
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,6 +196,50 @@ func TestMergeColumns_TypeConflictIsDetected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeColumns_SerialVsUnderlyingIntegerIsNotAConflict(t *testing.T) {
|
||||||
|
target := &models.Database{
|
||||||
|
Schemas: []*models.Schema{
|
||||||
|
{
|
||||||
|
Name: "public",
|
||||||
|
Tables: []*models.Table{
|
||||||
|
{
|
||||||
|
Name: "users",
|
||||||
|
Schema: "public",
|
||||||
|
Columns: map[string]*models.Column{
|
||||||
|
// As reported back by a live PostgreSQL read of an
|
||||||
|
// existing serial primary key column.
|
||||||
|
"id": {Name: "id", Type: "bigint"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
source := &models.Database{
|
||||||
|
Schemas: []*models.Schema{
|
||||||
|
{
|
||||||
|
Name: "public",
|
||||||
|
Tables: []*models.Table{
|
||||||
|
{
|
||||||
|
Name: "users",
|
||||||
|
Schema: "public",
|
||||||
|
Columns: map[string]*models.Column{
|
||||||
|
// As declared in a DBML source spec.
|
||||||
|
"id": {Name: "id", Type: "bigserial"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := MergeDatabases(target, source, nil)
|
||||||
|
|
||||||
|
if len(result.TypeConflicts) != 0 {
|
||||||
|
t.Fatalf("Expected no type conflicts for bigserial vs bigint, got %d: %+v", len(result.TypeConflicts), result.TypeConflicts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMergeConstraints_NewConstraint(t *testing.T) {
|
func TestMergeConstraints_NewConstraint(t *testing.T) {
|
||||||
target := &models.Database{
|
target := &models.Database{
|
||||||
Schemas: []*models.Schema{
|
Schemas: []*models.Schema{
|
||||||
|
|||||||
@@ -193,6 +193,28 @@ func IsKnownPGBaseType(baseType string) bool {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serialUnderlyingType maps each serial pseudo-type to the integer type
|
||||||
|
// PostgreSQL actually stores the column as. serial/bigserial/smallserial are
|
||||||
|
// not real types: they are sugar for an integer column plus a sequence
|
||||||
|
// default, and pg_catalog (and information_schema) always reports the
|
||||||
|
// underlying integer type back for such columns.
|
||||||
|
var serialUnderlyingType = map[string]string{
|
||||||
|
"serial": "integer",
|
||||||
|
"bigserial": "bigint",
|
||||||
|
"smallserial": "smallint",
|
||||||
|
}
|
||||||
|
|
||||||
|
// SerialUnderlyingType returns the underlying integer type for a serial
|
||||||
|
// pseudo-type (e.g. "bigserial" -> "bigint"). If baseType (after
|
||||||
|
// NormalizePGType) is not a serial type, it is returned unchanged.
|
||||||
|
func SerialUnderlyingType(baseType string) string {
|
||||||
|
normalized := NormalizePGType(baseType)
|
||||||
|
if underlying, ok := serialUnderlyingType[normalized]; ok {
|
||||||
|
return underlying
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
func IsGoType(pTypeName string) bool {
|
func IsGoType(pTypeName string) bool {
|
||||||
for k := range GoToStdTypes {
|
for k := range GoToStdTypes {
|
||||||
if strings.EqualFold(pTypeName, k) {
|
if strings.EqualFold(pTypeName, k) {
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ func (w *MigrationWriter) generateAlterTableScripts(schema *models.Schema, model
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check default value changes
|
// Check default value changes
|
||||||
if fmt.Sprintf("%v", modelCol.Default) != fmt.Sprintf("%v", currentCol.Default) {
|
if !columnDefaultsEqual(modelCol.Default, currentCol.Default) {
|
||||||
setDefault, defaultVal := formatColumnDefaultSQL(modelCol)
|
setDefault, defaultVal := formatColumnDefaultSQL(modelCol)
|
||||||
|
|
||||||
sql, err := w.executor.ExecuteAlterColumnDefaultWithCheck(AlterColumnDefaultWithCheckData{
|
sql, err := w.executor.ExecuteAlterColumnDefaultWithCheck(AlterColumnDefaultWithCheckData{
|
||||||
@@ -956,7 +956,24 @@ func columnsEqual(col1, col2 *models.Column) bool {
|
|||||||
}
|
}
|
||||||
return columnTypesEqual(col1, col2) &&
|
return columnTypesEqual(col1, col2) &&
|
||||||
col1.NotNull == col2.NotNull &&
|
col1.NotNull == col2.NotNull &&
|
||||||
fmt.Sprintf("%v", col1.Default) == fmt.Sprintf("%v", col2.Default)
|
columnDefaultsEqual(col1.Default, col2.Default)
|
||||||
|
}
|
||||||
|
|
||||||
|
// columnDefaultsEqual compares column defaults for drift detection, stripping
|
||||||
|
// MySQL-style backticks (e.g. from GORM tags) so a model default of
|
||||||
|
// "`now()`" is recognised as equal to a live default of "now()".
|
||||||
|
func columnDefaultsEqual(default1, default2 interface{}) bool {
|
||||||
|
return normalizeDefaultForCompare(default1) == normalizeDefaultForCompare(default2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDefaultForCompare(value interface{}) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if s, ok := value.(string); ok {
|
||||||
|
return strings.TrimSpace(stripBackticks(s))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func columnTypesEqual(col1, col2 *models.Column) bool {
|
func columnTypesEqual(col1, col2 *models.Column) bool {
|
||||||
|
|||||||
@@ -530,7 +530,7 @@ func BuildCreateTableData(schemaName string, table *models.Table) CreateTableDat
|
|||||||
}
|
}
|
||||||
if col.Default != nil {
|
if col.Default != nil {
|
||||||
if value, ok := col.Default.(string); ok {
|
if value, ok := col.Default.(string); ok {
|
||||||
colData.Default = writers.QuoteDefaultValue(value, col.Type)
|
colData.Default = writers.QuoteDefaultValue(stripBackticks(value), col.Type)
|
||||||
} else {
|
} else {
|
||||||
colData.Default = fmt.Sprintf("%v", col.Default)
|
colData.Default = fmt.Sprintf("%v", col.Default)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -512,7 +512,7 @@ func formatColumnDefaultSQL(col *models.Column) (setDefault bool, defaultVal str
|
|||||||
return false, ""
|
return false, ""
|
||||||
}
|
}
|
||||||
if value, ok := col.Default.(string); ok {
|
if value, ok := col.Default.(string); ok {
|
||||||
return true, writers.QuoteDefaultValue(value, col.Type)
|
return true, writers.QuoteDefaultValue(stripBackticks(value), col.Type)
|
||||||
}
|
}
|
||||||
return true, fmt.Sprintf("%v", col.Default)
|
return true, fmt.Sprintf("%v", col.Default)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1140,6 +1140,37 @@ func TestWriteSchema_EmitsGuardedAlterColumnDefaultStatements(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteSchema_AlterColumnDefaultStripsBackticksFromFunctionExpression(t *testing.T) {
|
||||||
|
db := models.InitDatabase("testdb")
|
||||||
|
schema := models.InitSchema("public")
|
||||||
|
|
||||||
|
table := models.InitTable("agent_skills", "public")
|
||||||
|
|
||||||
|
updatedAtCol := models.InitColumn("updatedat", "agent_skills", "public")
|
||||||
|
updatedAtCol.Type = "timestamp"
|
||||||
|
updatedAtCol.Default = "`now()`"
|
||||||
|
table.Columns["updatedat"] = updatedAtCol
|
||||||
|
|
||||||
|
schema.Tables = append(schema.Tables, table)
|
||||||
|
db.Schemas = append(db.Schemas, schema)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := NewWriter(&writers.WriterOptions{})
|
||||||
|
writer.writer = &buf
|
||||||
|
|
||||||
|
if err := writer.WriteDatabase(db); err != nil {
|
||||||
|
t.Fatalf("WriteDatabase failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := buf.String()
|
||||||
|
if strings.Contains(output, "`") {
|
||||||
|
t.Fatalf("expected no backticks in generated SQL, got:\n%s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "ALTER COLUMN updatedat SET DEFAULT now()") {
|
||||||
|
t.Fatalf("expected guarded SET DEFAULT now() without backticks, got:\n%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteSchema_GuardedAlterColumnTypeFallsBackOnConversionFailure(t *testing.T) {
|
func TestWriteSchema_GuardedAlterColumnTypeFallsBackOnConversionFailure(t *testing.T) {
|
||||||
db := models.InitDatabase("testdb")
|
db := models.InitDatabase("testdb")
|
||||||
schema := models.InitSchema("public")
|
schema := models.InitSchema("public")
|
||||||
|
|||||||
@@ -148,6 +148,26 @@ func SanitizeFilename(name string) string {
|
|||||||
// Examples (boolean): "true" → "true"
|
// Examples (boolean): "true" → "true"
|
||||||
// Examples (bigint): "0" → "0"
|
// Examples (bigint): "0" → "0"
|
||||||
// Examples (timestamp): "now()" → "now()" (function call – never quoted)
|
// Examples (timestamp): "now()" → "now()" (function call – never quoted)
|
||||||
|
// bareKeywordDefaults are PostgreSQL default-value keywords that are
|
||||||
|
// expressions, not string literals, even though they contain no
|
||||||
|
// parentheses (e.g. "CURRENT_DATE" rather than "now()"). They must never be
|
||||||
|
// wrapped in quotes.
|
||||||
|
var bareKeywordDefaults = map[string]bool{
|
||||||
|
"current_date": true,
|
||||||
|
"current_time": true,
|
||||||
|
"current_timestamp": true,
|
||||||
|
"localtime": true,
|
||||||
|
"localtimestamp": true,
|
||||||
|
"current_user": true,
|
||||||
|
"session_user": true,
|
||||||
|
"current_role": true,
|
||||||
|
"current_catalog": true,
|
||||||
|
"current_schema": true,
|
||||||
|
"null": true,
|
||||||
|
"true": true,
|
||||||
|
"false": true,
|
||||||
|
}
|
||||||
|
|
||||||
func QuoteDefaultValue(value, sqlType string) string {
|
func QuoteDefaultValue(value, sqlType string) string {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
|
|
||||||
@@ -158,6 +178,12 @@ func QuoteDefaultValue(value, sqlType string) string {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bare keyword expressions (e.g. CURRENT_DATE) are never quoted,
|
||||||
|
// regardless of column type.
|
||||||
|
if bareKeywordDefaults[strings.ToLower(value)] {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// Normalise the SQL type: lowercase, strip length/precision suffix.
|
// Normalise the SQL type: lowercase, strip length/precision suffix.
|
||||||
baseType := strings.ToLower(strings.TrimSpace(sqlType))
|
baseType := strings.ToLower(strings.TrimSpace(sqlType))
|
||||||
if idx := strings.Index(baseType, "("); idx > 0 {
|
if idx := strings.Index(baseType, "("); idx > 0 {
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ func TestQuoteDefaultValue(t *testing.T) {
|
|||||||
sqlType: "timestamptz",
|
sqlType: "timestamptz",
|
||||||
want: "now()",
|
want: "now()",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "bare keyword default CURRENT_DATE is not quoted",
|
||||||
|
value: "CURRENT_DATE",
|
||||||
|
sqlType: "date",
|
||||||
|
want: "CURRENT_DATE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare keyword default is case insensitive",
|
||||||
|
value: "current_timestamp",
|
||||||
|
sqlType: "timestamptz",
|
||||||
|
want: "current_timestamp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare keyword default localtime is not quoted",
|
||||||
|
value: "LOCALTIME",
|
||||||
|
sqlType: "time",
|
||||||
|
want: "LOCALTIME",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
Reference in New Issue
Block a user