feat(storage): add Dolt backend for version-controlled issue storage
Implements a complete Dolt storage backend that mirrors the SQLite implementation with MySQL-compatible syntax and adds version control capabilities. Key features: - Full Storage interface implementation (~50 methods) - Version control operations: commit, push, pull, branch, merge, checkout - History queries via AS OF and dolt_history_* tables - Cell-level merge instead of line-level JSONL merge - SQL injection protection with input validation Bug fixes applied during implementation: - Added missing quality_score, work_type, source_system to scanIssue - Fixed Status() to properly parse boolean staged column - Added validation to CreateIssues (was missing in batch create) - Made RenameDependencyPrefix transactional - Expanded GetIssueHistory to return more complete data Test coverage: 17 tests covering CRUD, dependencies, labels, search, comments, events, statistics, and SQL injection protection. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
547
internal/storage/dolt/queries.go
Normal file
547
internal/storage/dolt/queries.go
Normal file
@@ -0,0 +1,547 @@
|
||||
package dolt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// SearchIssues finds issues matching query and filters
|
||||
func (s *DoltStore) SearchIssues(ctx context.Context, query string, filter types.IssueFilter) ([]*types.Issue, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
whereClauses := []string{}
|
||||
args := []interface{}{}
|
||||
|
||||
if query != "" {
|
||||
whereClauses = append(whereClauses, "(title LIKE ? OR description LIKE ? OR id LIKE ?)")
|
||||
pattern := "%" + query + "%"
|
||||
args = append(args, pattern, pattern, pattern)
|
||||
}
|
||||
|
||||
if filter.TitleSearch != "" {
|
||||
whereClauses = append(whereClauses, "title LIKE ?")
|
||||
args = append(args, "%"+filter.TitleSearch+"%")
|
||||
}
|
||||
|
||||
if filter.TitleContains != "" {
|
||||
whereClauses = append(whereClauses, "title LIKE ?")
|
||||
args = append(args, "%"+filter.TitleContains+"%")
|
||||
}
|
||||
if filter.DescriptionContains != "" {
|
||||
whereClauses = append(whereClauses, "description LIKE ?")
|
||||
args = append(args, "%"+filter.DescriptionContains+"%")
|
||||
}
|
||||
if filter.NotesContains != "" {
|
||||
whereClauses = append(whereClauses, "notes LIKE ?")
|
||||
args = append(args, "%"+filter.NotesContains+"%")
|
||||
}
|
||||
|
||||
if filter.Status != nil {
|
||||
whereClauses = append(whereClauses, "status = ?")
|
||||
args = append(args, *filter.Status)
|
||||
} else if !filter.IncludeTombstones {
|
||||
whereClauses = append(whereClauses, "status != ?")
|
||||
args = append(args, types.StatusTombstone)
|
||||
}
|
||||
|
||||
if len(filter.ExcludeStatus) > 0 {
|
||||
placeholders := make([]string, len(filter.ExcludeStatus))
|
||||
for i, s := range filter.ExcludeStatus {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, string(s))
|
||||
}
|
||||
whereClauses = append(whereClauses, fmt.Sprintf("status NOT IN (%s)", strings.Join(placeholders, ",")))
|
||||
}
|
||||
|
||||
if len(filter.ExcludeTypes) > 0 {
|
||||
placeholders := make([]string, len(filter.ExcludeTypes))
|
||||
for i, t := range filter.ExcludeTypes {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, string(t))
|
||||
}
|
||||
whereClauses = append(whereClauses, fmt.Sprintf("issue_type NOT IN (%s)", strings.Join(placeholders, ",")))
|
||||
}
|
||||
|
||||
if filter.Priority != nil {
|
||||
whereClauses = append(whereClauses, "priority = ?")
|
||||
args = append(args, *filter.Priority)
|
||||
}
|
||||
if filter.PriorityMin != nil {
|
||||
whereClauses = append(whereClauses, "priority >= ?")
|
||||
args = append(args, *filter.PriorityMin)
|
||||
}
|
||||
if filter.PriorityMax != nil {
|
||||
whereClauses = append(whereClauses, "priority <= ?")
|
||||
args = append(args, *filter.PriorityMax)
|
||||
}
|
||||
|
||||
if filter.IssueType != nil {
|
||||
whereClauses = append(whereClauses, "issue_type = ?")
|
||||
args = append(args, *filter.IssueType)
|
||||
}
|
||||
|
||||
if filter.Assignee != nil {
|
||||
whereClauses = append(whereClauses, "assignee = ?")
|
||||
args = append(args, *filter.Assignee)
|
||||
}
|
||||
|
||||
// Date ranges
|
||||
if filter.CreatedAfter != nil {
|
||||
whereClauses = append(whereClauses, "created_at > ?")
|
||||
args = append(args, filter.CreatedAfter.Format(time.RFC3339))
|
||||
}
|
||||
if filter.CreatedBefore != nil {
|
||||
whereClauses = append(whereClauses, "created_at < ?")
|
||||
args = append(args, filter.CreatedBefore.Format(time.RFC3339))
|
||||
}
|
||||
if filter.UpdatedAfter != nil {
|
||||
whereClauses = append(whereClauses, "updated_at > ?")
|
||||
args = append(args, filter.UpdatedAfter.Format(time.RFC3339))
|
||||
}
|
||||
if filter.UpdatedBefore != nil {
|
||||
whereClauses = append(whereClauses, "updated_at < ?")
|
||||
args = append(args, filter.UpdatedBefore.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// Empty/null checks
|
||||
if filter.EmptyDescription {
|
||||
whereClauses = append(whereClauses, "(description IS NULL OR description = '')")
|
||||
}
|
||||
if filter.NoAssignee {
|
||||
whereClauses = append(whereClauses, "(assignee IS NULL OR assignee = '')")
|
||||
}
|
||||
if filter.NoLabels {
|
||||
whereClauses = append(whereClauses, "id NOT IN (SELECT DISTINCT issue_id FROM labels)")
|
||||
}
|
||||
|
||||
// Label filtering (AND)
|
||||
if len(filter.Labels) > 0 {
|
||||
for _, label := range filter.Labels {
|
||||
whereClauses = append(whereClauses, "id IN (SELECT issue_id FROM labels WHERE label = ?)")
|
||||
args = append(args, label)
|
||||
}
|
||||
}
|
||||
|
||||
// Label filtering (OR)
|
||||
if len(filter.LabelsAny) > 0 {
|
||||
placeholders := make([]string, len(filter.LabelsAny))
|
||||
for i, label := range filter.LabelsAny {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, label)
|
||||
}
|
||||
whereClauses = append(whereClauses, fmt.Sprintf("id IN (SELECT issue_id FROM labels WHERE label IN (%s))", strings.Join(placeholders, ", ")))
|
||||
}
|
||||
|
||||
// ID filtering
|
||||
if len(filter.IDs) > 0 {
|
||||
placeholders := make([]string, len(filter.IDs))
|
||||
for i, id := range filter.IDs {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
whereClauses = append(whereClauses, fmt.Sprintf("id IN (%s)", strings.Join(placeholders, ", ")))
|
||||
}
|
||||
|
||||
if filter.IDPrefix != "" {
|
||||
whereClauses = append(whereClauses, "id LIKE ?")
|
||||
args = append(args, filter.IDPrefix+"%")
|
||||
}
|
||||
|
||||
// Wisp filtering
|
||||
if filter.Ephemeral != nil {
|
||||
if *filter.Ephemeral {
|
||||
whereClauses = append(whereClauses, "ephemeral = 1")
|
||||
} else {
|
||||
whereClauses = append(whereClauses, "(ephemeral = 0 OR ephemeral IS NULL)")
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned filtering
|
||||
if filter.Pinned != nil {
|
||||
if *filter.Pinned {
|
||||
whereClauses = append(whereClauses, "pinned = 1")
|
||||
} else {
|
||||
whereClauses = append(whereClauses, "(pinned = 0 OR pinned IS NULL)")
|
||||
}
|
||||
}
|
||||
|
||||
// Template filtering
|
||||
if filter.IsTemplate != nil {
|
||||
if *filter.IsTemplate {
|
||||
whereClauses = append(whereClauses, "is_template = 1")
|
||||
} else {
|
||||
whereClauses = append(whereClauses, "(is_template = 0 OR is_template IS NULL)")
|
||||
}
|
||||
}
|
||||
|
||||
// Parent filtering
|
||||
if filter.ParentID != nil {
|
||||
whereClauses = append(whereClauses, "id IN (SELECT issue_id FROM dependencies WHERE type = 'parent-child' AND depends_on_id = ?)")
|
||||
args = append(args, *filter.ParentID)
|
||||
}
|
||||
|
||||
// Molecule type filtering
|
||||
if filter.MolType != nil {
|
||||
whereClauses = append(whereClauses, "mol_type = ?")
|
||||
args = append(args, string(*filter.MolType))
|
||||
}
|
||||
|
||||
// Time-based scheduling filters
|
||||
if filter.Deferred {
|
||||
whereClauses = append(whereClauses, "defer_until IS NOT NULL")
|
||||
}
|
||||
if filter.Overdue {
|
||||
whereClauses = append(whereClauses, "due_at IS NOT NULL AND due_at < ? AND status != ?")
|
||||
args = append(args, time.Now().Format(time.RFC3339), types.StatusClosed)
|
||||
}
|
||||
|
||||
whereSQL := ""
|
||||
if len(whereClauses) > 0 {
|
||||
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")
|
||||
}
|
||||
|
||||
limitSQL := ""
|
||||
if filter.Limit > 0 {
|
||||
limitSQL = fmt.Sprintf(" LIMIT %d", filter.Limit)
|
||||
}
|
||||
|
||||
querySQL := fmt.Sprintf(`
|
||||
SELECT id FROM issues
|
||||
%s
|
||||
ORDER BY priority ASC, created_at DESC
|
||||
%s
|
||||
`, whereSQL, limitSQL)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, querySQL, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search issues: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return s.scanIssueIDs(ctx, rows)
|
||||
}
|
||||
|
||||
// GetReadyWork returns issues that are ready to work on (not blocked)
|
||||
func (s *DoltStore) GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
whereClauses := []string{"status = 'open'", "(ephemeral = 0 OR ephemeral IS NULL)"}
|
||||
args := []interface{}{}
|
||||
|
||||
if filter.Priority != nil {
|
||||
whereClauses = append(whereClauses, "priority = ?")
|
||||
args = append(args, *filter.Priority)
|
||||
}
|
||||
if filter.Type != "" {
|
||||
whereClauses = append(whereClauses, "issue_type = ?")
|
||||
args = append(args, filter.Type)
|
||||
}
|
||||
if filter.Assignee != nil {
|
||||
whereClauses = append(whereClauses, "assignee = ?")
|
||||
args = append(args, *filter.Assignee)
|
||||
}
|
||||
if len(filter.Labels) > 0 {
|
||||
for _, label := range filter.Labels {
|
||||
whereClauses = append(whereClauses, "id IN (SELECT issue_id FROM labels WHERE label = ?)")
|
||||
args = append(args, label)
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude blocked issues using subquery
|
||||
whereClauses = append(whereClauses, `
|
||||
id NOT IN (
|
||||
SELECT DISTINCT d.issue_id
|
||||
FROM dependencies d
|
||||
JOIN issues blocker ON d.depends_on_id = blocker.id
|
||||
WHERE d.type = 'blocks'
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked')
|
||||
)
|
||||
`)
|
||||
|
||||
whereSQL := "WHERE " + strings.Join(whereClauses, " AND ")
|
||||
|
||||
limitSQL := ""
|
||||
if filter.Limit > 0 {
|
||||
limitSQL = fmt.Sprintf(" LIMIT %d", filter.Limit)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id FROM issues
|
||||
%s
|
||||
ORDER BY priority ASC, created_at DESC
|
||||
%s
|
||||
`, whereSQL, limitSQL)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get ready work: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return s.scanIssueIDs(ctx, rows)
|
||||
}
|
||||
|
||||
// GetBlockedIssues returns issues that are blocked by other issues
|
||||
func (s *DoltStore) GetBlockedIssues(ctx context.Context, filter types.WorkFilter) ([]*types.BlockedIssue, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT i.id, COUNT(d.depends_on_id) as blocked_by_count
|
||||
FROM issues i
|
||||
JOIN dependencies d ON i.id = d.issue_id
|
||||
JOIN issues blocker ON d.depends_on_id = blocker.id
|
||||
WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked')
|
||||
AND d.type = 'blocks'
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked')
|
||||
GROUP BY i.id
|
||||
ORDER BY i.priority ASC, i.created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get blocked issues: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []*types.BlockedIssue
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var count int
|
||||
if err := rows.Scan(&id, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
issue, err := s.GetIssue(ctx, id)
|
||||
if err != nil || issue == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get blocker IDs
|
||||
var blockerIDs []string
|
||||
blockerRows, err := s.db.QueryContext(ctx, `
|
||||
SELECT d.depends_on_id
|
||||
FROM dependencies d
|
||||
JOIN issues blocker ON d.depends_on_id = blocker.id
|
||||
WHERE d.issue_id = ?
|
||||
AND d.type = 'blocks'
|
||||
AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked')
|
||||
`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for blockerRows.Next() {
|
||||
var blockerID string
|
||||
if err := blockerRows.Scan(&blockerID); err != nil {
|
||||
blockerRows.Close()
|
||||
return nil, err
|
||||
}
|
||||
blockerIDs = append(blockerIDs, blockerID)
|
||||
}
|
||||
blockerRows.Close()
|
||||
|
||||
results = append(results, &types.BlockedIssue{
|
||||
Issue: *issue,
|
||||
BlockedByCount: count,
|
||||
BlockedBy: blockerIDs,
|
||||
})
|
||||
}
|
||||
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// GetEpicsEligibleForClosure returns epics whose children are all closed
|
||||
func (s *DoltStore) GetEpicsEligibleForClosure(ctx context.Context) ([]*types.EpicStatus, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT e.id,
|
||||
(SELECT COUNT(*) FROM dependencies d JOIN issues c ON d.issue_id = c.id
|
||||
WHERE d.depends_on_id = e.id AND d.type = 'parent-child') as total_children,
|
||||
(SELECT COUNT(*) FROM dependencies d JOIN issues c ON d.issue_id = c.id
|
||||
WHERE d.depends_on_id = e.id AND d.type = 'parent-child' AND c.status = 'closed') as closed_children
|
||||
FROM issues e
|
||||
WHERE e.issue_type = 'epic'
|
||||
AND e.status != 'closed'
|
||||
AND e.status != 'tombstone'
|
||||
HAVING total_children > 0 AND total_children = closed_children
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get epics eligible for closure: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []*types.EpicStatus
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var total, closed int
|
||||
if err := rows.Scan(&id, &total, &closed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
issue, err := s.GetIssue(ctx, id)
|
||||
if err != nil || issue == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, &types.EpicStatus{
|
||||
Epic: issue,
|
||||
TotalChildren: total,
|
||||
ClosedChildren: closed,
|
||||
EligibleForClose: total > 0 && total == closed,
|
||||
})
|
||||
}
|
||||
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// GetStaleIssues returns issues that haven't been updated recently
|
||||
func (s *DoltStore) GetStaleIssues(ctx context.Context, filter types.StaleFilter) ([]*types.Issue, error) {
|
||||
cutoff := time.Now().AddDate(0, 0, -filter.Days)
|
||||
|
||||
statusClause := "status IN ('open', 'in_progress')"
|
||||
if filter.Status != "" {
|
||||
statusClause = "status = ?"
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id FROM issues
|
||||
WHERE updated_at < ?
|
||||
AND %s
|
||||
AND (ephemeral = 0 OR ephemeral IS NULL)
|
||||
ORDER BY updated_at ASC
|
||||
`, statusClause)
|
||||
args := []interface{}{cutoff}
|
||||
if filter.Status != "" {
|
||||
args = append(args, filter.Status)
|
||||
}
|
||||
|
||||
if filter.Limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", filter.Limit)
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get stale issues: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return s.scanIssueIDs(ctx, rows)
|
||||
}
|
||||
|
||||
// GetStatistics returns summary statistics
|
||||
func (s *DoltStore) GetStatistics(ctx context.Context) (*types.Statistics, error) {
|
||||
stats := &types.Statistics{}
|
||||
|
||||
// Count by status
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_count,
|
||||
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
|
||||
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed,
|
||||
SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END) as blocked,
|
||||
SUM(CASE WHEN status = 'deferred' THEN 1 ELSE 0 END) as deferred,
|
||||
SUM(CASE WHEN status = 'tombstone' THEN 1 ELSE 0 END) as tombstone,
|
||||
SUM(CASE WHEN pinned = 1 THEN 1 ELSE 0 END) as pinned
|
||||
FROM issues
|
||||
WHERE status != 'tombstone'
|
||||
`).Scan(
|
||||
&stats.TotalIssues,
|
||||
&stats.OpenIssues,
|
||||
&stats.InProgressIssues,
|
||||
&stats.ClosedIssues,
|
||||
&stats.BlockedIssues,
|
||||
&stats.DeferredIssues,
|
||||
&stats.TombstoneIssues,
|
||||
&stats.PinnedIssues,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get statistics: %w", err)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetMoleculeProgress returns progress stats for a molecule
|
||||
func (s *DoltStore) GetMoleculeProgress(ctx context.Context, moleculeID string) (*types.MoleculeProgressStats, error) {
|
||||
stats := &types.MoleculeProgressStats{
|
||||
MoleculeID: moleculeID,
|
||||
}
|
||||
|
||||
// Get molecule title
|
||||
var title sql.NullString
|
||||
err := s.db.QueryRowContext(ctx, "SELECT title FROM issues WHERE id = ?", moleculeID).Scan(&title)
|
||||
if err == nil && title.Valid {
|
||||
stats.MoleculeTitle = title.String
|
||||
}
|
||||
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress
|
||||
FROM issues i
|
||||
JOIN dependencies d ON i.id = d.issue_id
|
||||
WHERE d.depends_on_id = ?
|
||||
AND d.type = 'parent-child'
|
||||
`, moleculeID).Scan(&stats.Total, &stats.Completed, &stats.InProgress)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get molecule progress: %w", err)
|
||||
}
|
||||
|
||||
// Get first in_progress step ID
|
||||
var stepID sql.NullString
|
||||
_ = s.db.QueryRowContext(ctx, `
|
||||
SELECT i.id FROM issues i
|
||||
JOIN dependencies d ON i.id = d.issue_id
|
||||
WHERE d.depends_on_id = ?
|
||||
AND d.type = 'parent-child'
|
||||
AND i.status = 'in_progress'
|
||||
ORDER BY i.created_at ASC
|
||||
LIMIT 1
|
||||
`, moleculeID).Scan(&stepID)
|
||||
if stepID.Valid {
|
||||
stats.CurrentStepID = stepID.String
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetNextChildID returns the next available child ID for a parent
|
||||
func (s *DoltStore) GetNextChildID(ctx context.Context, parentID string) (string, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Get or create counter
|
||||
var lastChild int
|
||||
err = tx.QueryRowContext(ctx, "SELECT last_child FROM child_counters WHERE parent_id = ?", parentID).Scan(&lastChild)
|
||||
if err == sql.ErrNoRows {
|
||||
lastChild = 0
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nextChild := lastChild + 1
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO child_counters (parent_id, last_child) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE last_child = ?
|
||||
`, parentID, nextChild, nextChild)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s.%d", parentID, nextChild), nil
|
||||
}
|
||||
Reference in New Issue
Block a user