Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions docs/workflow/modular-schema-files.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,46 @@ schema/

The key is ensuring your `main.sql` file contains the correct `\i` directives that match your chosen file organization.

### Folder Includes

The `\i` directive supports including entire folders by adding a trailing slash (`/`). This automatically includes all `.sql` files in the folder in alphabetical order, and recursively processes any subdirectories:

```
schema/
├── main.sql
├── types/
│ ├── address.sql
│ ├── order_status.sql
│ └── user_status.sql
├── tables/
│ ├── orders.sql
│ └── users.sql
└── functions/
├── auth/
│ └── validate_user.sql
├── calculate_total.sql
└── update_timestamp.sql
```

Using folder includes in `main.sql`:
```sql
-- Include all types (processed alphabetically: address, order_status, user_status)
\i types/

-- Include all tables (processed alphabetically: orders, users)
\i tables/

-- Include all functions recursively (auth/validate_user, calculate_total, update_timestamp)
\i functions/
```

Key behaviors:
- Files are processed in **alphabetical order** by filename
- Subdirectories are processed recursively using **depth-first search**
- Only `.sql` files are included; other files are ignored
- Folder paths must end with `/` to be recognized as folders
- Error if folder doesn't exist or if you try to include a file as a folder

### Nested Includes

The `\i` directive can be nested, allowing for hierarchical file organization:
Expand Down Expand Up @@ -282,4 +322,15 @@ With nested includes:
\i functions.sql
```

You can also combine folder includes with nested approaches:
```sql
-- main.sql - mix folder and file includes
\i core/
\i modules/auth/auth.sql
\i modules/reporting/

-- core/ folder contains individual files that get included alphabetically
-- modules/reporting/ folder gets all files included recursively
```

This approach allows for modular organization where each subsystem manages its own includes.
126 changes: 102 additions & 24 deletions internal/include/processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)

Expand Down Expand Up @@ -83,19 +84,28 @@ func (p *Processor) processIncludes(content string, currentDir string) (string,
if matches != nil {
// Found an include directive
includePath := matches[1]

// Resolve the include path
resolvedPath, err := p.resolveIncludePath(includePath, currentDir)
resolvedPath, isFolder, err := p.resolveIncludePath(includePath, currentDir)
if err != nil {
return "", fmt.Errorf("failed to resolve include path %s: %w", includePath, err)
}

// Process the included file recursively
includedContent, err := p.processFileRecursive(resolvedPath)
if err != nil {
return "", fmt.Errorf("failed to process included file %s: %w", resolvedPath, err)

var includedContent string
if isFolder {
// Process the folder recursively
includedContent, err = p.processFolderRecursive(resolvedPath)
if err != nil {
return "", fmt.Errorf("failed to process included folder %s: %w", resolvedPath, err)
}
} else {
// Process the included file recursively
includedContent, err = p.processFileRecursive(resolvedPath)
if err != nil {
return "", fmt.Errorf("failed to process included file %s: %w", resolvedPath, err)
}
}

// Split included content into lines and add them
includedLines := strings.Split(includedContent, "\n")
// Remove the last empty line if the content ends with \n
Expand All @@ -114,40 +124,108 @@ func (p *Processor) processIncludes(content string, currentDir string) (string,

// resolveIncludePath resolves an include path relative to the current directory
// Only allows files within the base directory and its subdirectories
func (p *Processor) resolveIncludePath(includePath string, currentDir string) (string, error) {
// Returns the resolved path and a flag indicating if it's a folder
func (p *Processor) resolveIncludePath(includePath string, currentDir string) (string, bool, error) {
// Check if this is a folder path (ends with /)
isFolder := strings.HasSuffix(includePath, "/")
Copy link

Copilot AI Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider trimming the trailing slash from includePath when isFolder is true to avoid potential path resolution issues. The current implementation may cause problems when constructing file paths later.

Suggested change
isFolder := strings.HasSuffix(includePath, "/")
isFolder := strings.HasSuffix(includePath, "/")
if isFolder {
includePath = strings.TrimSuffix(includePath, "/")
}

Copilot uses AI. Check for mistakes.

// Clean the path to remove any . or .. components
cleanPath := filepath.Clean(includePath)

// Check for directory traversal attempts
if strings.Contains(cleanPath, "..") {
return "", fmt.Errorf("directory traversal not allowed: %s", includePath)
return "", false, fmt.Errorf("directory traversal not allowed: %s", includePath)
}

// Resolve relative to current directory
resolvedPath := filepath.Join(currentDir, cleanPath)

// Get absolute path
absPath, err := filepath.Abs(resolvedPath)
if err != nil {
return "", fmt.Errorf("failed to get absolute path: %w", err)
return "", false, fmt.Errorf("failed to get absolute path: %w", err)
}

// Ensure the resolved path is within the base directory
baseAbs, err := filepath.Abs(p.baseDir)
if err != nil {
return "", fmt.Errorf("failed to get absolute base path: %w", err)
return "", false, fmt.Errorf("failed to get absolute base path: %w", err)
}

// Check if the resolved path is within the base directory
relPath, err := filepath.Rel(baseAbs, absPath)
if err != nil || strings.HasPrefix(relPath, "..") {
return "", fmt.Errorf("include path %s is outside the base directory %s", includePath, p.baseDir)
return "", false, fmt.Errorf("include path %s is outside the base directory %s", includePath, p.baseDir)
}

// Check if file exists
if _, err := os.Stat(absPath); os.IsNotExist(err) {
return "", fmt.Errorf("included file does not exist: %s", absPath)

// Check if path exists
stat, err := os.Stat(absPath)
if os.IsNotExist(err) {
if isFolder {
return "", false, fmt.Errorf("included folder does not exist: %s", absPath)
} else {
return "", false, fmt.Errorf("included file does not exist: %s", absPath)
}
}

return absPath, nil
if err != nil {
return "", false, fmt.Errorf("failed to stat path %s: %w", absPath, err)
}

// Validate that the path type matches the expectation
if isFolder && !stat.IsDir() {
return "", false, fmt.Errorf("expected folder but found file: %s", absPath)
}
if !isFolder && stat.IsDir() {
return "", false, fmt.Errorf("expected file but found folder: %s (use %s/ for folder includes)", absPath, includePath)
}

return absPath, isFolder, nil
}

// processFolderRecursive processes all .sql files in a folder using DFS
func (p *Processor) processFolderRecursive(folderPath string) (string, error) {
// Read directory contents
entries, err := os.ReadDir(folderPath)
if err != nil {
return "", fmt.Errorf("failed to read directory %s: %w", folderPath, err)
}

// Sort entries alphabetically (natural filename order)
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})

var resultParts []string

// Process each entry in alphabetical order
for _, entry := range entries {
entryPath := filepath.Join(folderPath, entry.Name())

if entry.IsDir() {
// Recursively process subdirectory (DFS)
subFolderContent, err := p.processFolderRecursive(entryPath)
if err != nil {
return "", fmt.Errorf("failed to process subdirectory %s: %w", entryPath, err)
}
if subFolderContent != "" {
resultParts = append(resultParts, subFolderContent)
}
} else if strings.HasSuffix(entry.Name(), ".sql") {
// Process .sql file
fileContent, err := p.processFileRecursive(entryPath)
if err != nil {
return "", fmt.Errorf("failed to process file %s: %w", entryPath, err)
}
if fileContent != "" {
// Ensure the file content ends with a newline for proper concatenation
if !strings.HasSuffix(fileContent, "\n") {
fileContent += "\n"
}
Comment on lines +220 to +223
Copy link

Copilot AI Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This newline handling logic is duplicated from the single file processing path. Consider extracting this into a helper method to avoid code duplication and ensure consistent behavior.

Copilot uses AI. Check for mistakes.
resultParts = append(resultParts, fileContent)
}
}
// Ignore non-.sql files
}

return strings.Join(resultParts, ""), nil
}
Loading