Created
April 23, 2025 12:43
-
-
Save garyblankenship/28b51d42edb52270fdede17204c24219 to your computer and use it in GitHub Desktop.
PocketBase Blueprint
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package main | |
import ( | |
"bytes" | |
"encoding/json" | |
"flag" | |
"fmt" | |
"io" | |
"io/ioutil" | |
"log" | |
"net/http" | |
"net/url" | |
"os" | |
"path" | |
"regexp" | |
"strings" | |
"time" | |
"github.com/iancoleman/strcase" | |
"gopkg.in/yaml.v3" | |
) | |
// --- PocketBase API Structures --- | |
// Auth Response | |
type AdminAuthResponse struct { | |
Token string `json:"token"` | |
Admin struct { | |
ID string `json:"id"` | |
Email string `json:"email"` | |
} `json:"admin"` | |
} | |
// Collection List Item (Simplified) | |
type PBListCollectionItem struct { | |
ID string `json:"id"` | |
Name string `json:"name"` | |
Type string `json:"type"` | |
System bool `json:"system"` | |
Schema []PocketBaseField `json:"schema"` // Include schema for comparison/update | |
ListRule *string `json:"listRule"` | |
ViewRule *string `json:"viewRule"` | |
CreateRule *string `json:"createRule"` | |
UpdateRule *string `json:"updateRule"` | |
DeleteRule *string `json:"deleteRule"` | |
Options map[string]interface{} `json:"options"` | |
} | |
// API Response for listing collections | |
type PBListCollectionsResponse struct { | |
Page int `json:"page"` | |
PerPage int `json:"perPage"` | |
TotalItems int `json:"totalItems"` | |
TotalPages int `json:"totalPages"` | |
Items []PBListCollectionItem `json:"items"` | |
} | |
// Structures match the previous script (PocketBaseSchema, PocketBaseField, PocketBaseOptions) | |
type PocketBaseSchema struct { | |
ID string `json:"id,omitempty"` // Needed for updates if known | |
Name string `json:"name"` | |
Type string `json:"type"` // "base", "auth", "view" | |
System bool `json:"system"` | |
Schema []PocketBaseField `json:"schema"` | |
Indexes []string `json:"indexes,omitempty"` // Usually managed by PB | |
ListRule *string `json:"listRule"` | |
ViewRule *string `json:"viewRule"` | |
CreateRule *string `json:"createRule"` | |
UpdateRule *string `json:"updateRule"` | |
DeleteRule *string `json:"deleteRule"` | |
Options map[string]interface{} `json:"options"` | |
} | |
type PocketBaseField struct { | |
ID string `json:"id,omitempty"` // PB assigns this | |
Name string `json:"name"` | |
Type string `json:"type"` | |
System bool `json:"system"` | |
Required bool `json:"required"` | |
Presentable bool `json:"presentable"` | |
Unique bool `json:"unique"` | |
Options PocketBaseOptions `json:"options"` | |
} | |
type PocketBaseOptions map[string]interface{} | |
// --- Blueprint YAML Structures (same as before) --- | |
type BlueprintDraft struct { | |
Models map[string]map[string]interface{} `yaml:"models"` | |
Controllers interface{} `yaml:"controllers,omitempty"` | |
Seeders interface{} `yaml:"seeders,omitempty"` | |
} | |
// --- Globals and Regex (same as before) --- | |
var ( | |
httpClient = &http.Client{Timeout: 15 * time.Second} | |
adminToken = "" // Store authenticated token globally | |
pbBaseURL = "" // Store base URL globally | |
fieldRegex = regexp.MustCompile(`^([\w:]+)(?:\(([^)]+)\))?((?:\s+\w+(?::'?[\w\s,]+'?)?)*)$`) | |
relationshipRegex = regexp.MustCompile(`^(\w+)\s+([\w\/]+)(.*)$`) | |
modifierRegex = regexp.MustCompile(`\s+(\w+)(?::'?([\w\s,]+)'?)?`) | |
) | |
// Intermediate structure to hold relation info before IDs are known | |
type PendingRelation struct { | |
OwnerCollectionName string // Name of the collection this field belongs to | |
FieldName string // Name of the relation field (snake_case) | |
TargetModelName string // Original Blueprint model name of the target | |
IsMultiple bool // Is this a hasMany/belongsToMany style relation? | |
Required bool // Is the relation required? | |
CascadeDelete bool // Should cascade delete be enabled? | |
} | |
func main() { | |
// --- Command Line Flags --- | |
inputFile := flag.String("i", "draft.yml", "Input Blueprint YAML file") | |
pbURL := flag.String("url", "http://127.0.0.1:8090", "PocketBase instance URL") | |
adminEmail := flag.String("email", "", "PocketBase Admin Email") | |
adminPassword := flag.String("password", "", "PocketBase Admin Password") | |
defaultListRule := flag.String("listRule", "", "Default PocketBase listRule (eg. \"@request.auth.id != ''\") - leave empty for null") | |
defaultViewRule := flag.String("viewRule", "", "Default PocketBase viewRule") | |
defaultCreateRule := flag.String("createRule", "", "Default PocketBase createRule") | |
defaultUpdateRule := flag.String("updateRule", "", "Default PocketBase updateRule") | |
defaultDeleteRule := flag.String("deleteRule", "", "Default PocketBase deleteRule") | |
flag.Parse() | |
if *adminEmail == "" || *adminPassword == "" { | |
log.Fatal("Admin email and password are required (-email, -password)") | |
} | |
pbBaseURL = *pbURL // Store globally | |
// --- Read YAML --- | |
yamlFile, err := ioutil.ReadFile(*inputFile) | |
if err != nil { | |
log.Fatalf("Error reading YAML file %s: %v", *inputFile, err) | |
} | |
var draft BlueprintDraft | |
err = yaml.Unmarshal(yamlFile, &draft) | |
if err != nil { | |
log.Fatalf("Error unmarshalling YAML: %v", err) | |
} | |
// Convert models definitions to map[string]string | |
convertedModels := make(map[string]map[string]string) | |
for modelName, fields := range draft.Models { | |
convFields := make(map[string]string) | |
for fname, def := range fields { | |
if s, ok := def.(string); ok { | |
convFields[fname] = s | |
} else { | |
convFields[fname] = fmt.Sprintf("%v", def) | |
} | |
} | |
convertedModels[modelName] = convFields | |
} | |
if len(convertedModels) == 0 { | |
log.Fatalf("No 'models' section found in %s", *inputFile) | |
} | |
// --- Authenticate with PocketBase --- | |
err = authenticateAdmin(*adminEmail, *adminPassword) | |
if err != nil { | |
log.Fatalf("Authentication failed: %v", err) | |
} | |
fmt.Println("Successfully authenticated with PocketBase.") | |
// --- Get Existing Collections --- | |
existingCollections, err := getExistingCollections() // Map name -> PBListCollectionItem | |
if err != nil { | |
log.Fatalf("Failed to fetch existing collections: %v", err) | |
} | |
fmt.Printf("Found %d existing user collections.\n", len(existingCollections)) | |
// --- Pass 1: Create/Update Collections (Basic Fields Only) --- | |
fmt.Println("\n--- Pass 1: Creating/Updating Collections (Basic Fields) ---") | |
collectionNameMap := make(map[string]string) // Map Original Model Name -> snake_case name | |
collectionIDMap := make(map[string]string) // Map snake_case name -> PocketBase ID | |
pendingRelations := []PendingRelation{} // Store relations to process in Pass 2 | |
processedSchemas := make(map[string]PocketBaseSchema) // Store schemas generated in Pass 1 | |
for modelName := range convertedModels { | |
collectionName := strcase.ToSnake(modelName) | |
collectionNameMap[modelName] = collectionName | |
} | |
for modelName, fields := range convertedModels { | |
collectionName := collectionNameMap[modelName] | |
fmt.Printf("Processing Model: %s -> Collection: %s\n", modelName, collectionName) | |
pbSchema := PocketBaseSchema{ | |
Name: collectionName, | |
Type: "base", // Default | |
System: false, | |
Schema: []PocketBaseField{}, | |
Options: make(map[string]interface{}), | |
} | |
// Apply default rules | |
applyDefaultRules(&pbSchema, *defaultListRule, *defaultViewRule, *defaultCreateRule, *defaultUpdateRule, *defaultDeleteRule) | |
// Detect Auth Collection | |
detectAuthCollection(modelName, fields, &pbSchema) | |
processedFields := make(map[string]bool) | |
// --- Iterate through Blueprint fields --- | |
for fieldName, definition := range fields { | |
fieldNameSnake := strcase.ToSnake(fieldName) | |
if processedFields[fieldNameSnake] || shouldSkipField(fieldName) { | |
continue | |
} | |
// Check for relationship first (only store it for Pass 2) | |
isRelation, relInfo := checkForRelationship(definition, fieldNameSnake, collectionName, modelName, fields) | |
if isRelation { | |
pendingRelations = append(pendingRelations, relInfo) | |
// If it's a belongsTo, we might have a foreignId field defined too. | |
// Mark the conventional foreign key name (e.g., user_id) as processed | |
// if this relationship defines it implicitly. | |
if strings.ToLower(relInfo.FieldName) != fieldNameSnake { // e.g., definition is 'author: belongsTo User' but fieldNameSnake is 'author' | |
fkGuess := strcase.ToSnake(fieldName) + "_id" // Guess the FK field name | |
if _, hasExplicitFkField := fields[fkGuess]; !hasExplicitFkField { // If there isn't a separate author_id: foreignId field defined | |
processedFields[fkGuess] = true // Avoid processing author_id separately later | |
fmt.Printf(" - Marked implicit FK '%s' as handled by relationship '%s'\n", fkGuess, fieldNameSnake) | |
} | |
} | |
processedFields[fieldNameSnake] = true // Mark the relationship name itself as processed | |
fmt.Printf(" - Storing relationship '%s' for Pass 2\n", fieldNameSnake) | |
continue // Skip adding relation fields in Pass 1 | |
} | |
// If not a relationship, parse as standard field | |
fmt.Printf(" - Processing Field: %s (%s)\n", fieldNameSnake, definition) | |
pbField, err := mapBlueprintFieldToPocketBase(fieldNameSnake, definition, fields) | |
if err != nil { | |
log.Printf(" ! Warning: %v. Skipping field.", err) | |
continue | |
} | |
// Special handling for foreignId: treat as TEXT in pass 1 if we didn't store a pending relation for it | |
// This is a fallback in case the 'relation: belongsTo Model' syntax wasn't used. | |
if pbField.Type == "relation" && !isPendingRelation(pendingRelations, collectionName, fieldNameSnake) { | |
fmt.Printf(" ! Warning: Field '%s' looks like a foreign key but no corresponding 'belongsTo' relation found. Creating as 'text' for now. Define relationships explicitly (e.g., 'author: belongsTo User') for proper relation fields.\n", fieldNameSnake) | |
pbField.Type = "text" // Fallback: store the ID as text in Pass 1 | |
pbField.Options = PocketBaseOptions{} // Clear relation options | |
} | |
// Only add non-relation fields in Pass 1 | |
if pbField.Type != "relation" { | |
pbSchema.Schema = append(pbSchema.Schema, pbField) | |
processedFields[fieldNameSnake] = true | |
} else { | |
// This case should ideally be handled by checkForRelationship storing a PendingRelation | |
fmt.Printf(" > Skipping relation-type field '%s' in Pass 1 (will be handled by explicit relationship definition or fallback).\n", fieldNameSnake) | |
processedFields[fieldNameSnake] = true // Mark as processed | |
} | |
} // End field loop | |
// --- Send API Request (Create or Update) --- | |
existing, exists := existingCollections[collectionName] | |
var createdOrUpdatedCollection PBListCollectionItem | |
var apiErr error | |
if exists { | |
fmt.Printf(" > Collection '%s' exists (ID: %s). Updating...\n", collectionName, existing.ID) | |
pbSchema.ID = existing.ID // Ensure ID is set for PATCH | |
createdOrUpdatedCollection, apiErr = updateCollection(pbSchema) | |
collectionIDMap[collectionName] = existing.ID // Store existing ID | |
} else { | |
fmt.Printf(" > Collection '%s' does not exist. Creating...\n", collectionName) | |
createdOrUpdatedCollection, apiErr = createCollection(pbSchema) | |
if apiErr == nil { | |
collectionIDMap[collectionName] = createdOrUpdatedCollection.ID // Store new ID | |
} | |
} | |
if apiErr != nil { | |
log.Printf(" ! Error processing collection %s: %v", collectionName, apiErr) | |
// Optionally decide if the whole process should stop on error | |
// continue // Continue with the next collection despite error | |
os.Exit(1) // Or exit immediately | |
} else { | |
// Store the schema state *after* Pass 1 API call, including the ID | |
finalSchema := PocketBaseSchema{ // Convert from PBListCollectionItem back to PocketBaseSchema | |
ID: createdOrUpdatedCollection.ID, | |
Name: createdOrUpdatedCollection.Name, | |
Type: createdOrUpdatedCollection.Type, | |
System: createdOrUpdatedCollection.System, | |
Schema: createdOrUpdatedCollection.Schema, | |
ListRule: createdOrUpdatedCollection.ListRule, | |
ViewRule: createdOrUpdatedCollection.ViewRule, | |
CreateRule: createdOrUpdatedCollection.CreateRule, | |
UpdateRule: createdOrUpdatedCollection.UpdateRule, | |
DeleteRule: createdOrUpdatedCollection.DeleteRule, | |
Options: createdOrUpdatedCollection.Options, | |
} | |
processedSchemas[collectionName] = finalSchema | |
fmt.Printf(" > Successfully processed '%s' (ID: %s).\n", collectionName, collectionIDMap[collectionName]) | |
} | |
} // End model loop (Pass 1) | |
// --- Pass 2: Update Collections with Relation Fields --- | |
fmt.Println("\n--- Pass 2: Updating Collections with Relation Fields ---") | |
if len(pendingRelations) == 0 { | |
fmt.Println("No relations defined in draft.yml. Skipping Pass 2.") | |
} | |
// Group pending relations by the collection they belong to | |
relationsByOwner := make(map[string][]PendingRelation) | |
for _, rel := range pendingRelations { | |
relationsByOwner[rel.OwnerCollectionName] = append(relationsByOwner[rel.OwnerCollectionName], rel) | |
} | |
// Process relations for each collection that has them | |
for ownerCollectionName, ownerRelations := range relationsByOwner { | |
fmt.Printf("Processing relations for collection: %s\n", ownerCollectionName) | |
currentSchema, ok := processedSchemas[ownerCollectionName] | |
if !ok { | |
log.Printf(" ! Error: Schema for owner collection '%s' not found from Pass 1. Skipping relations.", ownerCollectionName) | |
continue | |
} | |
schemaNeedsUpdate := false | |
updatedSchemaFields := make([]PocketBaseField, len(currentSchema.Schema)) | |
copy(updatedSchemaFields, currentSchema.Schema) // Start with existing fields | |
existingFieldNames := make(map[string]int) // Map name to index in updatedSchemaFields | |
for i, f := range updatedSchemaFields { | |
existingFieldNames[f.Name] = i | |
} | |
for _, rel := range ownerRelations { | |
targetCollectionName, ok := collectionNameMap[rel.TargetModelName] | |
if !ok { | |
log.Printf(" ! Error: Target model '%s' for relation '%s' on '%s' not found in draft.yml. Skipping.", rel.TargetModelName, rel.FieldName, ownerCollectionName) | |
continue | |
} | |
targetCollectionID, ok := collectionIDMap[targetCollectionName] | |
if !ok { | |
log.Printf(" ! Error: ID for target collection '%s' not found (was it created successfully in Pass 1?). Skipping relation '%s'.", targetCollectionName, rel.FieldName) | |
continue | |
} | |
fmt.Printf(" - Adding/Updating relation field: %s -> %s (ID: %s)\n", rel.FieldName, targetCollectionName, targetCollectionID) | |
pbField := PocketBaseField{ | |
Name: rel.FieldName, | |
Type: "relation", | |
System: false, | |
Required: rel.Required, | |
Presentable: true, | |
Unique: false, // Usually false for relations, except maybe some hasOne | |
Options: PocketBaseOptions{ | |
"collectionId": targetCollectionID, // Use the actual ID! | |
"cascadeDelete": rel.CascadeDelete, | |
"minSelect": nil, | |
"maxSelect": nil, // Default unlimited for multiple | |
"displayFields": nil, // User configures this | |
}, | |
} | |
if rel.IsMultiple { | |
pbField.Options["maxSelect"] = nil // Many relation | |
} else { | |
pbField.Options["maxSelect"] = 1 // Single relation (belongsTo, hasOne) | |
} | |
// Set minSelect if required | |
if rel.Required { | |
pbField.Options["minSelect"] = 1 | |
} else { | |
pbField.Options["minSelect"] = nil // Explicitly set nil for optional | |
} | |
// Check if the field already exists (maybe from a previous run or manual creation) | |
if index, fieldExists := existingFieldNames[rel.FieldName]; fieldExists { | |
// Field exists, check if it needs updating (type or options changed) | |
existingField := updatedSchemaFields[index] | |
if existingField.Type != "relation" || !areOptionsEqual(existingField.Options, pbField.Options) { | |
fmt.Printf(" > Updating existing field '%s' to relation.\n", rel.FieldName) | |
updatedSchemaFields[index] = pbField // Replace the existing field definition | |
schemaNeedsUpdate = true | |
} else { | |
fmt.Printf(" > Relation field '%s' already exists and seems up-to-date.\n", rel.FieldName) | |
} | |
} else { | |
// Field doesn't exist, add it | |
fmt.Printf(" > Adding new relation field '%s'.\n", rel.FieldName) | |
updatedSchemaFields = append(updatedSchemaFields, pbField) | |
schemaNeedsUpdate = true | |
existingFieldNames[rel.FieldName] = len(updatedSchemaFields)-1 // Update index map | |
} | |
} // End loop through relations for this owner | |
// If any relations were added/updated for this collection, send PATCH request | |
if schemaNeedsUpdate { | |
fmt.Printf(" > Sending update for collection '%s' with new/modified relations...\n", ownerCollectionName) | |
currentSchema.Schema = updatedSchemaFields // Update the schema array in the payload | |
_, apiErr := updateCollection(currentSchema) // Send PATCH with the updated schema list | |
if apiErr != nil { | |
log.Printf(" ! Error updating collection %s with relations: %v", ownerCollectionName, apiErr) | |
// Continue or exit based on desired robustness | |
} else { | |
fmt.Printf(" > Successfully updated '%s' with relations.\n", ownerCollectionName) | |
} | |
} else { | |
fmt.Printf(" > No relation updates needed for '%s'.\n", ownerCollectionName) | |
} | |
} // End loop through owners (Pass 2) | |
fmt.Println("\n--- Sync Process Completed ---") | |
fmt.Println("Review PocketBase UI and logs for any potential issues.") | |
} | |
// --- Helper Functions --- | |
// Simplified check for option equality (only checks keys used in relation) | |
func areOptionsEqual(o1, o2 PocketBaseOptions) bool { | |
keys := []string{"collectionId", "cascadeDelete", "minSelect", "maxSelect"} | |
for _, k := range keys { | |
v1, ok1 := o1[k] | |
v2, ok2 := o2[k] | |
if ok1 != ok2 || fmt.Sprintf("%v", v1) != fmt.Sprintf("%v", v2) { // Basic value comparison | |
return false | |
} | |
} | |
return true | |
} | |
// Check if a relation is already stored in pendingRelations | |
func isPendingRelation(pending []PendingRelation, ownerName, fieldName string) bool { | |
for _, p := range pending { | |
if p.OwnerCollectionName == ownerName && p.FieldName == fieldName { | |
return true | |
} | |
} | |
return false | |
} | |
func applyDefaultRules(schema *PocketBaseSchema, list, view, create, update, delete string) { | |
if list != "" { schema.ListRule = &list } else { schema.ListRule = nil } | |
if view != "" { schema.ViewRule = &view } else { schema.ViewRule = nil } | |
if create != "" { schema.CreateRule = &create } else { schema.CreateRule = nil } | |
if update != "" { schema.UpdateRule = &update } else { schema.UpdateRule = nil } | |
if delete != "" { schema.DeleteRule = &delete } else { schema.DeleteRule = nil } | |
} | |
func detectAuthCollection(modelName string, fields map[string]string, schema *PocketBaseSchema) { | |
if strings.EqualFold(modelName, "user") || strings.EqualFold(modelName, "auth") { | |
hasEmail := false | |
hasPassword := false | |
hasUsername := false // Check for username auth possibility | |
for fieldName := range fields { | |
lFieldName := strings.ToLower(fieldName) | |
if lFieldName == "email" { hasEmail = true } | |
if strings.Contains(lFieldName, "password") { hasPassword = true } // Basic check | |
if lFieldName == "username" { hasUsername = true} | |
} | |
if hasEmail && hasPassword { | |
fmt.Printf(" > Detected potential auth collection: %s\n", schema.Name) | |
schema.Type = "auth" | |
schema.Options["allowEmailAuth"] = true | |
schema.Options["allowOAuth2Auth"] = true // Default true | |
schema.Options["allowUsernameAuth"] = hasUsername // Enable if username field found | |
schema.Options["exceptEmailDomains"] = nil | |
schema.Options["manageRule"] = nil | |
schema.Options["onlyEmailDomains"] = nil | |
// PocketBase automatically adds username/email constraints based on allow options | |
// schema.Options["requireEmail"] = true // PB handles this based on allowEmailAuth | |
} | |
} | |
} | |
func shouldSkipField(fieldName string) bool { | |
// Skip special Blueprint fields handled by PocketBase implicitly or not applicable | |
skip := map[string]bool{ | |
"id": true, | |
"timestamps": true, | |
"softDeletes": true, | |
"rememberToken": true, | |
} | |
_, shouldSkip := skip[fieldName] | |
if shouldSkip { | |
fmt.Printf(" - Skipping Blueprint meta-field: %s\n", fieldName) | |
} | |
return shouldSkip | |
} | |
// Checks if a blueprint definition string defines a relationship. | |
// If it does, returns true and a PendingRelation struct. | |
func checkForRelationship(definition, fieldNameSnake, ownerCollectionName, ownerModelName string, allFields map[string]string) (bool, PendingRelation) { | |
relMatch := relationshipRegex.FindStringSubmatch(definition) | |
if len(relMatch) == 0 { | |
return false, PendingRelation{} // Not a relationship definition like "author: belongsTo User" | |
} | |
relType := strings.ToLower(relMatch[1]) | |
relModel := relMatch[2] | |
// relModifiersStr := strings.TrimSpace(relMatch[3]) // TODO: Parse modifiers like foreignKey for cascadeDelete? | |
isMultiple := false | |
cascadeDelete := false // Default false, maybe infer from modifiers later | |
switch relType { | |
case "belongsto", "hasone", "morphto": // Treat as single relation | |
isMultiple = false | |
case "hasmany", "belongstomany", "morphmany", "morphtomany": // Treat as multiple relation | |
isMultiple = true | |
default: | |
fmt.Printf(" ! Warning: Unsupported relationship type '%s' for field '%s'. Skipping relation.\n", relType, fieldNameSnake) | |
return false, PendingRelation{} // Treat as not a valid relation for processing | |
} | |
// Determine required status from original definition if possible | |
required := true // Default to required for belongsTo unless nullable found | |
if isMultiple { | |
required = false // Many relations are usually not required | |
} else { | |
_, _, modifiersStr := parseFieldDefinition(definition) | |
modifiers := parseModifiers(modifiersStr) | |
if _, nullable := modifiers["nullable"]; nullable { | |
required = false | |
} | |
} | |
// Look for explicit foreign key definition to check for `constrained` | |
fkFieldNameGuess := fieldNameSnake + "_id" // e.g., author -> author_id | |
if fkDef, ok := allFields[fkFieldNameGuess]; ok { | |
_, _, modifiersStr := parseFieldDefinition(fkDef) | |
modifiers := parseModifiers(modifiersStr) | |
if _, constrained := modifiers["constrained"]; constrained { | |
fmt.Printf(" > Found 'constrained' on FK field '%s', enabling cascade delete for relation '%s'.\n", fkFieldNameGuess, fieldNameSnake) | |
cascadeDelete = true | |
} | |
} | |
return true, PendingRelation{ | |
OwnerCollectionName: ownerCollectionName, | |
FieldName: fieldNameSnake, | |
TargetModelName: relModel, | |
IsMultiple: isMultiple, | |
Required: required, | |
CascadeDelete: cascadeDelete, | |
} | |
} | |
// Maps a single Blueprint field definition (non-relationship) to PocketBaseField | |
func mapBlueprintFieldToPocketBase(fieldNameSnake, definition string, allFields map[string]string) (PocketBaseField, error) { | |
fieldType, typeOptionsStr, modifiersStr := parseFieldDefinition(definition) | |
modifiers := parseModifiers(modifiersStr) | |
pbField := PocketBaseField{ | |
Name: fieldNameSnake, | |
Type: "text", // Default | |
System: false, | |
Required: true, // Default required unless 'nullable' | |
Presentable: true, | |
Unique: false, | |
Options: make(PocketBaseOptions), | |
} | |
if _, exists := modifiers["nullable"]; exists { | |
pbField.Required = false | |
} | |
if _, exists := modifiers["unique"]; exists { | |
pbField.Unique = true | |
} | |
// PocketBase handles default values differently (not in schema definition) | |
parts := strings.Split(fieldType, ":") | |
baseType := parts[0] | |
typeArgs := "" | |
if len(parts) > 1 { typeArgs = parts[1] } | |
// --- Type Mapping Logic (mostly same as previous script) --- | |
switch baseType { | |
case "string", "char": | |
pbField.Type = "text" | |
if typeArgs != "" { pbField.Options["max"] = parseInt(typeArgs, 0) } | |
pbField.Options["min"] = nil; pbField.Options["pattern"] = "" | |
// Infer email/url | |
lFieldName := strings.ToLower(fieldNameSnake) | |
if strings.Contains(lFieldName, "email") { | |
pbField.Type = "email"; delete(pbField.Options, "max"); delete(pbField.Options, "pattern"); pbField.Options["exceptDomains"] = nil; pbField.Options["onlyDomains"] = nil | |
} else if strings.Contains(lFieldName, "url") || strings.Contains(lFieldName, "website") { | |
pbField.Type = "url"; delete(pbField.Options, "max"); delete(pbField.Options, "pattern"); pbField.Options["exceptDomains"] = nil; pbField.Options["onlyDomains"] = nil | |
} | |
case "integer", "tinyint", "smallint", "mediumint", "bigint", "unsignedInteger", "unsignedTinyint", "unsignedSmallint", "unsignedMediumint", "unsignedBigint", "increments", "tinyIncrements", "smallIncrements", "mediumIncrements", "bigIncrements": | |
pbField.Type = "number"; pbField.Options["min"] = nil; pbField.Options["max"] = nil | |
case "float", "double", "decimal", "unsignedDecimal": | |
pbField.Type = "number"; pbField.Options["min"] = nil; pbField.Options["max"] = nil | |
case "boolean": | |
pbField.Type = "bool" | |
case "date", "datetime", "datetimez", "timestamp", "timestampz", "time", "timez", "year": | |
pbField.Type = "date"; pbField.Options["min"] = ""; pbField.Options["max"] = "" | |
case "text", "tinytext", "mediumtext", "longtext": | |
pbField.Type = "text"; pbField.Options["min"] = nil; pbField.Options["max"] = nil; pbField.Options["pattern"] = "" | |
case "json", "jsonb": | |
pbField.Type = "json" | |
case "uuid": | |
pbField.Type = "text"; // Optional: pbField.Options["pattern"] = "..." | |
case "enum": | |
pbField.Type = "select" | |
values := []string{} | |
if typeOptionsStr != "" { values = strings.Split(typeOptionsStr, ",") } | |
for i := range values { values[i] = strings.TrimSpace(values[i]) } | |
pbField.Options["values"] = values; pbField.Options["maxSelect"] = 1 | |
case "file": // Handle Blueprint file type | |
pbField.Type = "file" | |
pbField.Options["maxSelect"] = 1 // Default to single file | |
pbField.Options["maxSize"] = 5242880 // Default 5MB | |
pbField.Options["mimeTypes"] = []string{} // Allow any by default | |
pbField.Options["thumbs"] = []string{} // No thumbs by default | |
// Check for modifiers like 'multiple' if needed | |
case "foreignId", "foreignUuid": | |
// This is tricky. If handled by an explicit relation definition, it's skipped earlier. | |
// If reached here, it means only `user_id: foreignId` was defined. | |
// We *could* try to infer the relation here, but it's better handled by Pass 2 from explicit definitions. | |
// For Pass 1, we'll mark it as a relation type but with a placeholder collectionId. Pass 2 *should* ideally overwrite this if an explicit relation exists. If not, it might remain a text field (see logic in main loop). | |
pbField.Type = "relation" // Tentative type | |
pbField.Options["maxSelect"] = 1 | |
pbField.Options["minSelect"] = nil | |
if pbField.Required { pbField.Options["minSelect"] = 1 } | |
pbField.Options["collectionId"] = "PLACEHOLDER_" + strings.TrimSuffix(fieldNameSnake, "_id") // Placeholder | |
pbField.Options["cascadeDelete"] = false | |
if _, constrained := modifiers["constrained"]; constrained { | |
pbField.Options["cascadeDelete"] = true | |
} | |
pbField.Options["displayFields"] = nil | |
// A warning about this fallback case is printed in the main loop if needed. | |
default: | |
return pbField, fmt.Errorf("unmapped Blueprint type '%s' for field '%s'. Defaulting to PocketBase type 'text'", baseType, fieldNameSnake) | |
} | |
return pbField, nil | |
} | |
// --- PocketBase API Interaction --- | |
func makeAPIRequest(method, apiPath string, payload interface{}) ([]byte, error) { | |
var reqBody io.Reader | |
if payload != nil { | |
jsonData, err := json.Marshal(payload) | |
if err != nil { | |
return nil, fmt.Errorf("failed to marshal payload: %w", err) | |
} | |
reqBody = bytes.NewBuffer(jsonData) | |
fmt.Printf("DEBUG: %s %s Payload: %s\n", method, apiPath, string(jsonData)) | |
} | |
// Debug the full URL being requested | |
fullURL, err := url.Parse(pbBaseURL) | |
if err != nil { | |
return nil, fmt.Errorf("invalid base URL: %w", err) | |
} | |
// Ensure the API path starts with /api/ | |
if !strings.HasPrefix(apiPath, "/api/") { | |
apiPath = "/api/" + strings.TrimPrefix(apiPath, "/") | |
} | |
// Handle query parameters separately | |
pathParts := strings.SplitN(apiPath, "?", 2) | |
fullURL.Path = path.Join(fullURL.Path, pathParts[0]) | |
if len(pathParts) > 1 { | |
fullURL.RawQuery = pathParts[1] | |
} | |
fmt.Printf("DEBUG: Full URL: %s\n", fullURL.String()) | |
req, err := http.NewRequest(method, fullURL.String(), reqBody) | |
if err != nil { | |
return nil, fmt.Errorf("failed to create request: %w", err) | |
} | |
req.Header.Set("Content-Type", "application/json") | |
if adminToken != "" { | |
req.Header.Set("Authorization", adminToken) | |
} | |
fmt.Printf("DEBUG: Making request to: %s %s\n", req.Method, req.URL.String()) | |
resp, err := httpClient.Do(req) | |
if err != nil { | |
return nil, fmt.Errorf("request failed: %w", err) | |
} | |
fmt.Printf("DEBUG: Response status: %s\n", resp.Status) | |
defer resp.Body.Close() | |
respBody, err := ioutil.ReadAll(resp.Body) | |
if err != nil { | |
return nil, fmt.Errorf("failed to read response body: %w", err) | |
} | |
if resp.StatusCode < 200 || resp.StatusCode >= 300 { | |
return respBody, fmt.Errorf("API error: %s (%d) - %s", http.StatusText(resp.StatusCode), resp.StatusCode, string(respBody)) | |
} | |
return respBody, nil | |
} | |
func authenticateAdmin(email, password string) error { | |
// First verify the instance is reachable | |
_, err := makeAPIRequest(http.MethodGet, "/api/health", nil) | |
if err != nil { | |
return fmt.Errorf("failed to reach PocketBase instance: %w\n" + | |
"Please ensure:\n" + | |
"1. The PocketBase instance is running at the specified URL\n" + | |
"2. The URL is correct and accessible", err) | |
} | |
authPayload := map[string]string{ | |
"identity": email, | |
"password": password, | |
} | |
// Try admin authentication | |
respBody, err := makeAPIRequest(http.MethodPost, "/api/collections/_superusers/auth-with-password", authPayload) | |
if err != nil { | |
if strings.Contains(err.Error(), "400") { | |
return fmt.Errorf("invalid admin credentials\n" + | |
"Please ensure:\n" + | |
"1. You're using the correct admin email and password\n" + | |
"2. The admin account exists and is enabled\n" + | |
"3. The password is correct") | |
} | |
return fmt.Errorf("admin authentication failed: %w", err) | |
} | |
var authResp AdminAuthResponse | |
if err := json.Unmarshal(respBody, &authResp); err != nil { | |
return fmt.Errorf("failed to parse auth response: %w - Body: %s", err, string(respBody)) | |
} | |
if authResp.Token == "" { | |
return fmt.Errorf("authentication succeeded but token is empty") | |
} | |
adminToken = authResp.Token // Store token globally | |
return nil | |
} | |
func getExistingCollections() (map[string]PBListCollectionItem, error) { | |
collections := make(map[string]PBListCollectionItem) | |
page := 1 | |
for { | |
// Use the correct collections endpoint for v0.26.3 | |
apiPath := fmt.Sprintf("/api/collections?page=%d&perPage=500", page) | |
fmt.Printf("DEBUG: Making request to: %s\n", apiPath) | |
respBody, err := makeAPIRequest(http.MethodGet, apiPath, nil) | |
if err != nil { | |
return nil, fmt.Errorf("failed to list collections (page %d): %w", page, err) | |
} | |
var listResp PBListCollectionsResponse | |
if err := json.Unmarshal(respBody, &listResp); err != nil { | |
return nil, fmt.Errorf("failed to parse collection list response (page %d): %w - Body: %s", page, err, string(respBody)) | |
} | |
for _, item := range listResp.Items { | |
collections[item.Name] = item | |
} | |
if listResp.Page >= listResp.TotalPages { | |
break // No more pages | |
} | |
page++ | |
} | |
return collections, nil | |
} | |
func createCollection(schema PocketBaseSchema) (PBListCollectionItem, error) { | |
var createdCollection PBListCollectionItem | |
respBody, err := makeAPIRequest(http.MethodPost, "/api/collections", schema) | |
if err != nil { | |
return createdCollection, err | |
} | |
if err := json.Unmarshal(respBody, &createdCollection); err != nil { | |
return createdCollection, fmt.Errorf("failed to parse created collection response: %w - Body: %s", err, string(respBody)) | |
} | |
return createdCollection, nil | |
} | |
func updateCollection(schema PocketBaseSchema) (PBListCollectionItem, error) { | |
if schema.ID == "" { | |
return PBListCollectionItem{}, fmt.Errorf("cannot update collection '%s', ID is missing", schema.Name) | |
} | |
var updatedCollection PBListCollectionItem | |
apiPath := fmt.Sprintf("/api/collections/%s", schema.ID) | |
// Send the whole schema definition for update. PocketBase PATCH should handle replacing/updating fields. | |
respBody, err := makeAPIRequest(http.MethodPatch, apiPath, schema) | |
if err != nil { | |
return updatedCollection, err | |
} | |
if err := json.Unmarshal(respBody, &updatedCollection); err != nil { | |
// Sometimes PATCH returns 204 No Content on success with empty body | |
if len(respBody) == 0 { | |
fmt.Printf(" > Update for '%s' returned 204 No Content (assumed successful).\n", schema.Name) | |
// Return the schema we sent, assuming it was applied | |
// PB doesn't return the full object on 204 | |
tempSchema := PBListCollectionItem{ | |
ID: schema.ID, Name: schema.Name, Type: schema.Type, System: schema.System, | |
Schema: schema.Schema, ListRule: schema.ListRule, ViewRule: schema.ViewRule, | |
CreateRule: schema.CreateRule, UpdateRule: schema.UpdateRule, DeleteRule: schema.DeleteRule, | |
Options: schema.Options, | |
} | |
return tempSchema, nil | |
} | |
return updatedCollection, fmt.Errorf("failed to parse updated collection response: %w - Body: %s", err, string(respBody)) | |
} | |
return updatedCollection, nil | |
} | |
// --- YAML/Blueprint Parsing Helpers (mostly same as before) --- | |
func parseFieldDefinition(def string) (fieldType, typeOptions string, modifiers string) { | |
match := fieldRegex.FindStringSubmatch(def) | |
if len(match) < 4 { | |
parts := strings.Fields(def) | |
if len(parts) > 0 { | |
fieldType = parts[0] | |
if len(parts) > 1 { modifiers = strings.Join(parts[1:], " ") } | |
} | |
return | |
} | |
fieldType = match[1]; typeOptions = match[2]; modifiers = strings.TrimSpace(match[3]) | |
return | |
} | |
func parseModifiers(modStr string) map[string]string { | |
mods := make(map[string]string) | |
matches := modifierRegex.FindAllStringSubmatch(modStr, -1) | |
for _, match := range matches { | |
if len(match) > 1 { | |
key := match[1]; value := ""; if len(match) > 2 { value = match[2] } | |
mods[key] = value | |
} | |
} | |
return mods | |
} | |
func parseInt(s string, defaultVal int) int { | |
var i int; _, err := fmt.Sscan(s, &i); if err != nil { return defaultVal }; return i | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Usage:
go run pb_blueprint.go -url "http://127.0.0.1:8090" -email "[email protected]" -password "password" -i "draft.yml"