fix(dolt): parse timestamps from TEXT columns instead of direct time.Time scan
The Dolt storage was scanning created_at and updated_at directly into time.Time fields, but SQLite stores these as TEXT strings. The Go SQLite driver cannot automatically convert TEXT to time.Time. Added parseTimeString() helper and fixed all scan functions: - issues.go: scanIssue() - dependencies.go: scanIssueRow() - history.go: GetIssueHistory(), GetIssueAsOf() - transaction.go: scanIssueTx() Fixes bd-4dqmy Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
b63e7b8cf0
commit
2cc96197c0
@@ -523,6 +523,7 @@ func (s *DoltStore) GetIssuesByIDs(ctx context.Context, ids []string) ([]*types.
|
|||||||
// scanIssueRow scans a single issue from a rows result
|
// scanIssueRow scans a single issue from a rows result
|
||||||
func scanIssueRow(rows *sql.Rows) (*types.Issue, error) {
|
func scanIssueRow(rows *sql.Rows) (*types.Issue, error) {
|
||||||
var issue types.Issue
|
var issue types.Issue
|
||||||
|
var createdAtStr, updatedAtStr sql.NullString // TEXT columns - must parse manually
|
||||||
var closedAt, compactedAt, deletedAt, lastActivity, dueAt, deferUntil sql.NullTime
|
var closedAt, compactedAt, deletedAt, lastActivity, dueAt, deferUntil sql.NullTime
|
||||||
var estimatedMinutes, originalSize, timeoutNs sql.NullInt64
|
var estimatedMinutes, originalSize, timeoutNs sql.NullInt64
|
||||||
var assignee, externalRef, compactedAtCommit, owner sql.NullString
|
var assignee, externalRef, compactedAtCommit, owner sql.NullString
|
||||||
@@ -538,7 +539,7 @@ func scanIssueRow(rows *sql.Rows) (*types.Issue, error) {
|
|||||||
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
||||||
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
|
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
|
||||||
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
|
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
|
||||||
&issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRef,
|
&createdAtStr, &issue.CreatedBy, &owner, &updatedAtStr, &closedAt, &externalRef,
|
||||||
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
|
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
|
||||||
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
||||||
&sender, &ephemeral, &pinned, &isTemplate, &crystallizes,
|
&sender, &ephemeral, &pinned, &isTemplate, &crystallizes,
|
||||||
@@ -551,6 +552,14 @@ func scanIssueRow(rows *sql.Rows) (*types.Issue, error) {
|
|||||||
return nil, fmt.Errorf("failed to scan issue row: %w", err)
|
return nil, fmt.Errorf("failed to scan issue row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse timestamp strings (TEXT columns require manual parsing)
|
||||||
|
if createdAtStr.Valid {
|
||||||
|
issue.CreatedAt = parseTimeString(createdAtStr.String)
|
||||||
|
}
|
||||||
|
if updatedAtStr.Valid {
|
||||||
|
issue.UpdatedAt = parseTimeString(updatedAtStr.String)
|
||||||
|
}
|
||||||
|
|
||||||
// Map nullable fields
|
// Map nullable fields
|
||||||
if contentHash.Valid {
|
if contentHash.Valid {
|
||||||
issue.ContentHash = contentHash.String
|
issue.ContentHash = contentHash.String
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ func (s *DoltStore) GetIssueHistory(ctx context.Context, issueID string) ([]*Iss
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var h IssueHistory
|
var h IssueHistory
|
||||||
var issue types.Issue
|
var issue types.Issue
|
||||||
|
var createdAtStr, updatedAtStr sql.NullString // TEXT columns - must parse manually
|
||||||
var closedAt sql.NullTime
|
var closedAt sql.NullTime
|
||||||
var assignee, owner, createdBy, closeReason, molType sql.NullString
|
var assignee, owner, createdBy, closeReason, molType sql.NullString
|
||||||
var estimatedMinutes sql.NullInt64
|
var estimatedMinutes sql.NullInt64
|
||||||
@@ -82,13 +83,21 @@ func (s *DoltStore) GetIssueHistory(ctx context.Context, issueID string) ([]*Iss
|
|||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&issue.ID, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes,
|
&issue.ID, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes,
|
||||||
&issue.Status, &issue.Priority, &issue.IssueType, &assignee, &owner, &createdBy,
|
&issue.Status, &issue.Priority, &issue.IssueType, &assignee, &owner, &createdBy,
|
||||||
&estimatedMinutes, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &closeReason,
|
&estimatedMinutes, &createdAtStr, &updatedAtStr, &closedAt, &closeReason,
|
||||||
&pinned, &molType,
|
&pinned, &molType,
|
||||||
&h.CommitHash, &h.Committer, &h.CommitDate,
|
&h.CommitHash, &h.Committer, &h.CommitDate,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan history: %w", err)
|
return nil, fmt.Errorf("failed to scan history: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse timestamp strings (TEXT columns require manual parsing)
|
||||||
|
if createdAtStr.Valid {
|
||||||
|
issue.CreatedAt = parseTimeString(createdAtStr.String)
|
||||||
|
}
|
||||||
|
if updatedAtStr.Valid {
|
||||||
|
issue.UpdatedAt = parseTimeString(updatedAtStr.String)
|
||||||
|
}
|
||||||
|
|
||||||
if closedAt.Valid {
|
if closedAt.Valid {
|
||||||
issue.ClosedAt = &closedAt.Time
|
issue.ClosedAt = &closedAt.Time
|
||||||
}
|
}
|
||||||
@@ -130,6 +139,7 @@ func (s *DoltStore) GetIssueAsOf(ctx context.Context, issueID string, ref string
|
|||||||
}
|
}
|
||||||
|
|
||||||
var issue types.Issue
|
var issue types.Issue
|
||||||
|
var createdAtStr, updatedAtStr sql.NullString // TEXT columns - must parse manually
|
||||||
var closedAt sql.NullTime
|
var closedAt sql.NullTime
|
||||||
var assignee, owner, contentHash sql.NullString
|
var assignee, owner, contentHash sql.NullString
|
||||||
var estimatedMinutes sql.NullInt64
|
var estimatedMinutes sql.NullInt64
|
||||||
@@ -144,7 +154,7 @@ func (s *DoltStore) GetIssueAsOf(ctx context.Context, issueID string, ref string
|
|||||||
|
|
||||||
err := s.db.QueryRowContext(ctx, query, issueID).Scan(
|
err := s.db.QueryRowContext(ctx, query, issueID).Scan(
|
||||||
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
|
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
|
||||||
&issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt,
|
&createdAtStr, &issue.CreatedBy, &owner, &updatedAtStr, &closedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -154,6 +164,14 @@ func (s *DoltStore) GetIssueAsOf(ctx context.Context, issueID string, ref string
|
|||||||
return nil, fmt.Errorf("failed to get issue as of %s: %w", ref, err)
|
return nil, fmt.Errorf("failed to get issue as of %s: %w", ref, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse timestamp strings (TEXT columns require manual parsing)
|
||||||
|
if createdAtStr.Valid {
|
||||||
|
issue.CreatedAt = parseTimeString(createdAtStr.String)
|
||||||
|
}
|
||||||
|
if updatedAtStr.Valid {
|
||||||
|
issue.UpdatedAt = parseTimeString(updatedAtStr.String)
|
||||||
|
}
|
||||||
|
|
||||||
if contentHash.Valid {
|
if contentHash.Valid {
|
||||||
issue.ContentHash = contentHash.String
|
issue.ContentHash = contentHash.String
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -410,6 +410,7 @@ func insertIssue(ctx context.Context, tx *sql.Tx, issue *types.Issue) error {
|
|||||||
|
|
||||||
func scanIssue(ctx context.Context, db *sql.DB, id string) (*types.Issue, error) {
|
func scanIssue(ctx context.Context, db *sql.DB, id string) (*types.Issue, error) {
|
||||||
var issue types.Issue
|
var issue types.Issue
|
||||||
|
var createdAtStr, updatedAtStr sql.NullString // TEXT columns - must parse manually
|
||||||
var closedAt, compactedAt, deletedAt, lastActivity, dueAt, deferUntil sql.NullTime
|
var closedAt, compactedAt, deletedAt, lastActivity, dueAt, deferUntil sql.NullTime
|
||||||
var estimatedMinutes, originalSize, timeoutNs sql.NullInt64
|
var estimatedMinutes, originalSize, timeoutNs sql.NullInt64
|
||||||
var assignee, externalRef, compactedAtCommit, owner sql.NullString
|
var assignee, externalRef, compactedAtCommit, owner sql.NullString
|
||||||
@@ -439,7 +440,7 @@ func scanIssue(ctx context.Context, db *sql.DB, id string) (*types.Issue, error)
|
|||||||
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
||||||
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
|
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
|
||||||
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
|
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
|
||||||
&issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRef,
|
&createdAtStr, &issue.CreatedBy, &owner, &updatedAtStr, &closedAt, &externalRef,
|
||||||
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
|
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
|
||||||
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
&deletedAt, &deletedBy, &deleteReason, &originalType,
|
||||||
&sender, &ephemeral, &pinned, &isTemplate, &crystallizes,
|
&sender, &ephemeral, &pinned, &isTemplate, &crystallizes,
|
||||||
@@ -457,6 +458,14 @@ func scanIssue(ctx context.Context, db *sql.DB, id string) (*types.Issue, error)
|
|||||||
return nil, fmt.Errorf("failed to get issue: %w", err)
|
return nil, fmt.Errorf("failed to get issue: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse timestamp strings (TEXT columns require manual parsing)
|
||||||
|
if createdAtStr.Valid {
|
||||||
|
issue.CreatedAt = parseTimeString(createdAtStr.String)
|
||||||
|
}
|
||||||
|
if updatedAtStr.Valid {
|
||||||
|
issue.UpdatedAt = parseTimeString(updatedAtStr.String)
|
||||||
|
}
|
||||||
|
|
||||||
// Map nullable fields
|
// Map nullable fields
|
||||||
if contentHash.Valid {
|
if contentHash.Valid {
|
||||||
issue.ContentHash = contentHash.String
|
issue.ContentHash = contentHash.String
|
||||||
|
|||||||
@@ -376,6 +376,7 @@ func insertIssueTx(ctx context.Context, tx *sql.Tx, issue *types.Issue) error {
|
|||||||
|
|
||||||
func scanIssueTx(ctx context.Context, tx *sql.Tx, id string) (*types.Issue, error) {
|
func scanIssueTx(ctx context.Context, tx *sql.Tx, id string) (*types.Issue, error) {
|
||||||
var issue types.Issue
|
var issue types.Issue
|
||||||
|
var createdAtStr, updatedAtStr sql.NullString // TEXT columns - must parse manually
|
||||||
var closedAt sql.NullTime
|
var closedAt sql.NullTime
|
||||||
var estimatedMinutes sql.NullInt64
|
var estimatedMinutes sql.NullInt64
|
||||||
var assignee, owner, contentHash sql.NullString
|
var assignee, owner, contentHash sql.NullString
|
||||||
@@ -392,7 +393,7 @@ func scanIssueTx(ctx context.Context, tx *sql.Tx, id string) (*types.Issue, erro
|
|||||||
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
|
||||||
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
|
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
|
||||||
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
|
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
|
||||||
&issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt,
|
&createdAtStr, &issue.CreatedBy, &owner, &updatedAtStr, &closedAt,
|
||||||
&ephemeral, &pinned, &isTemplate, &crystallizes,
|
&ephemeral, &pinned, &isTemplate, &crystallizes,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -403,6 +404,14 @@ func scanIssueTx(ctx context.Context, tx *sql.Tx, id string) (*types.Issue, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse timestamp strings (TEXT columns require manual parsing)
|
||||||
|
if createdAtStr.Valid {
|
||||||
|
issue.CreatedAt = parseTimeString(createdAtStr.String)
|
||||||
|
}
|
||||||
|
if updatedAtStr.Valid {
|
||||||
|
issue.UpdatedAt = parseTimeString(updatedAtStr.String)
|
||||||
|
}
|
||||||
|
|
||||||
if contentHash.Valid {
|
if contentHash.Valid {
|
||||||
issue.ContentHash = contentHash.String
|
issue.ContentHash = contentHash.String
|
||||||
}
|
}
|
||||||
|
|||||||
38
internal/storage/dolt/util.go
Normal file
38
internal/storage/dolt/util.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package dolt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseTimeString parses a time string from database TEXT columns (non-nullable).
|
||||||
|
// Used for required timestamp fields like created_at/updated_at when stored as TEXT.
|
||||||
|
// Returns zero time if parsing fails, which maintains backwards compatibility.
|
||||||
|
func parseTimeString(s string) time.Time {
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
// Try RFC3339Nano first (more precise), then RFC3339, then SQLite format
|
||||||
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05"} {
|
||||||
|
if t, err := time.Parse(layout, s); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{} // Unparseable - shouldn't happen with valid data
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNullableTimeString parses a nullable time string from database TEXT columns.
|
||||||
|
// For columns declared as TEXT (not DATETIME), we must parse manually.
|
||||||
|
// Supports RFC3339, RFC3339Nano, and SQLite's native format.
|
||||||
|
func parseNullableTimeString(ns sql.NullString) *time.Time {
|
||||||
|
if !ns.Valid || ns.String == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Try RFC3339Nano first (more precise), then RFC3339, then SQLite format
|
||||||
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05"} {
|
||||||
|
if t, err := time.Parse(layout, ns.String); err == nil {
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil // Unparseable - shouldn't happen with valid data
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user