yao/agent/store/xun/assistant.go
Max 0c7d635966 chore(dependencies): update AWS SDK and related packages to latest versions
- Upgraded AWS SDK for Go v2 and its related packages in go.mod and go.sum to improve compatibility and access to new features.
- Updated session management logic in the Runner to ensure proper handling of chat sessions.
- Added new unit tests for keyword search functionality in the Assistant model, enhancing search capabilities across locales.
- Refactored JSON handling in the Assistant package to streamline options configuration.
2026-04-08 17:24:35 +08:00

941 lines
26 KiB
Go

package xun
import (
"fmt"
"math"
"strings"
"time"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/xun/dbal/query"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
searchTypes "github.com/yaoapp/yao/agent/search/types"
"github.com/yaoapp/yao/agent/store/types"
)
func sandboxForDB(a *types.AssistantModel) interface{} {
if a.SandboxV2 != nil {
return a.SandboxV2
}
return a.Sandbox
}
// SaveAssistant saves assistant information
func (store *Xun) SaveAssistant(assistant *types.AssistantModel) (string, error) {
if assistant == nil {
return "", fmt.Errorf("assistant cannot be nil")
}
// Validate required fields
if assistant.Name == "" {
return "", fmt.Errorf("field name is required")
}
if assistant.Type == "" {
return "", fmt.Errorf("field type is required")
}
if assistant.Connector == "" {
return "", fmt.Errorf("field connector is required")
}
// Generate assistant_id if not provided
if assistant.ID == "" {
var err error
assistant.ID, err = store.GenerateAssistantID()
if err != nil {
return "", err
}
}
// Check if assistant exists
exists, err := store.query.New().
Table(store.getAssistantTable()).
Where("assistant_id", assistant.ID).
Exists()
if err != nil {
return "", err
}
// Convert model to map for database storage
data := make(map[string]interface{})
data["assistant_id"] = assistant.ID
data["type"] = assistant.Type
data["connector"] = assistant.Connector
data["built_in"] = assistant.BuiltIn
data["sort"] = assistant.Sort
data["readonly"] = assistant.Readonly
data["public"] = assistant.Public
data["mentionable"] = assistant.Mentionable
data["automated"] = assistant.Automated
data["disable_global_prompts"] = assistant.DisableGlobalPrompts
// Use UTC time.Time so the DB driver serialises correctly for all dialects
// (PostgreSQL timestamptz, MySQL datetime, SQLite text) with no TZ ambiguity.
now := time.Now().UTC()
if exists {
if assistant.UpdatedAt == 0 {
data["updated_at"] = now
} else {
data["updated_at"] = nanoToTime(assistant.UpdatedAt)
}
} else {
if assistant.CreatedAt == 0 {
data["created_at"] = now
} else {
data["created_at"] = nanoToTime(assistant.CreatedAt)
}
data["updated_at"] = nil
}
// Handle nullable string fields from assistant.mod.yao
// Store as nil if empty string (this matches database nullable: true fields)
if assistant.Name != "" {
data["name"] = assistant.Name
} else {
data["name"] = nil
}
if assistant.Avatar != "" {
data["avatar"] = assistant.Avatar
} else {
data["avatar"] = nil
}
if assistant.Description != "" {
data["description"] = assistant.Description
} else {
data["description"] = nil
}
if assistant.Capabilities != "" {
data["capabilities"] = assistant.Capabilities
} else {
data["capabilities"] = nil
}
if assistant.Path != "" {
data["path"] = assistant.Path
} else {
data["path"] = nil
}
if assistant.Source != "" {
data["source"] = assistant.Source
} else {
data["source"] = nil
}
// Share field: nullable: false with default "private"
// Apply default if empty
if assistant.Share != "" {
data["share"] = assistant.Share
} else {
data["share"] = "private" // Apply default value
}
// Permission management fields - store as nil if empty
if assistant.YaoCreatedBy != "" {
data["__yao_created_by"] = assistant.YaoCreatedBy
} else {
data["__yao_created_by"] = nil
}
if assistant.YaoUpdatedBy != "" {
data["__yao_updated_by"] = assistant.YaoUpdatedBy
} else {
data["__yao_updated_by"] = nil
}
if assistant.YaoTeamID != "" {
data["__yao_team_id"] = assistant.YaoTeamID
} else {
data["__yao_team_id"] = nil
}
if assistant.YaoTenantID != "" {
data["__yao_tenant_id"] = assistant.YaoTenantID
} else {
data["__yao_tenant_id"] = nil
}
// DefaultMode is a simple string field
if assistant.DefaultMode != "" {
data["default_mode"] = assistant.DefaultMode
} else {
data["default_mode"] = nil
}
// Handle all JSON fields uniformly via marshalJSONFields.
// Uses isNil() to correctly skip typed nils stored in interface{}.
jsonFields := map[string]interface{}{
"options": assistant.Options,
"tags": assistant.Tags,
"modes": assistant.Modes,
"prompts": assistant.Prompts,
"prompt_presets": assistant.PromptPresets,
"connector_options": assistant.ConnectorOptions,
"kb": assistant.KB,
"db": assistant.DB,
"mcp": assistant.MCP,
"workflow": assistant.Workflow,
"sandbox": sandboxForDB(assistant),
"placeholder": assistant.Placeholder,
"locales": assistant.Locales,
"uses": assistant.Uses,
"search": assistant.Search,
"dependencies": assistant.Dependencies,
}
if err := marshalJSONFields(data, jsonFields); err != nil {
return "", err
}
// Update or insert
if exists {
_, err := store.query.New().
Table(store.getAssistantTable()).
Where("assistant_id", assistant.ID).
Update(data)
if err != nil {
return "", err
}
return assistant.ID, nil
}
err = store.query.New().
Table(store.getAssistantTable()).
Insert(data)
if err != nil {
return "", err
}
return assistant.ID, nil
}
// UpdateAssistant updates specific fields of an assistant
func (store *Xun) UpdateAssistant(assistantID string, updates map[string]interface{}) error {
if assistantID == "" {
return fmt.Errorf("assistant_id is required")
}
if len(updates) == 0 {
return fmt.Errorf("no fields to update")
}
// Check if assistant exists
exists, err := store.query.New().
Table(store.getAssistantTable()).
Where("assistant_id", assistantID).
Exists()
if err != nil {
return err
}
if !exists {
return fmt.Errorf("assistant %s not found", assistantID)
}
// Prepare update data
data := make(map[string]interface{})
// List of fields that need JSON marshaling
jsonFields := []string{"options", "tags", "modes", "prompts", "prompt_presets", "connector_options", "kb", "db", "mcp", "workflow", "sandbox", "placeholder", "locales", "uses", "search", "dependencies"}
jsonFieldSet := make(map[string]bool)
for _, field := range jsonFields {
jsonFieldSet[field] = true
}
// List of nullable string fields
nullableStringFields := []string{"name", "avatar", "description", "capabilities", "path", "source", "default_mode", "__yao_created_by", "__yao_updated_by", "__yao_team_id", "__yao_tenant_id"}
nullableFieldSet := make(map[string]bool)
for _, field := range nullableStringFields {
nullableFieldSet[field] = true
}
// Process each update field
for key, value := range updates {
// Skip system fields that shouldn't be updated directly
if key == "assistant_id" || key == "created_at" {
continue
}
// Handle JSON fields
if jsonFieldSet[key] {
if isNil(value) {
data[key] = nil
} else {
jsonStr, err := jsoniter.MarshalToString(value)
if err != nil {
return fmt.Errorf("failed to marshal %s: %w", key, err)
}
data[key] = jsonStr
}
} else {
// Handle regular fields
// Convert empty strings to nil for nullable fields
if strVal, ok := value.(string); ok && strVal == "" && nullableFieldSet[key] {
data[key] = nil
continue
}
data[key] = value
}
}
// Always update updated_at timestamp (UTC time.Time for dialect portability)
data["updated_at"] = time.Now().UTC()
if len(data) == 0 {
return fmt.Errorf("no valid fields to update")
}
// Perform update
_, err = store.query.New().
Table(store.getAssistantTable()).
Where("assistant_id", assistantID).
Update(data)
return err
}
// DeleteAssistant deletes an assistant by assistant_id
func (store *Xun) DeleteAssistant(assistantID string) error {
// Check if assistant exists
exists, err := store.query.New().
Table(store.getAssistantTable()).
Where("assistant_id", assistantID).
Exists()
if err != nil {
return err
}
if !exists {
return fmt.Errorf("assistant %s not found", assistantID)
}
_, err = store.query.New().
Table(store.getAssistantTable()).
Where("assistant_id", assistantID).
Delete()
return err
}
// GetAssistants retrieves assistants with pagination and filtering
func (store *Xun) GetAssistants(filter types.AssistantFilter, locale ...string) (*types.AssistantList, error) {
qb := store.query.New().
Table(store.getAssistantTable())
// Apply tag filter if provided
if len(filter.Tags) > 0 {
qb.Where(func(qb query.Query) {
for i, tag := range filter.Tags {
val := store.jsonContainsValue(fmt.Sprintf("%%\"%s\"%%", tag))
if i == 0 {
qb.WhereJSONContains("tags", val)
} else {
qb.OrWhereJSONContains("tags", val)
}
}
})
}
// Apply keyword filter if provided
if filter.Keywords != "" {
kw := fmt.Sprintf("%%%s%%", filter.Keywords)
qb.Where(func(qb query.Query) {
qb.Where("name", "like", kw).
OrWhere("description", "like", kw).
OrWhere("capabilities", "like", kw).
OrWhereRaw(store.localesLikeSQL(filter.Keywords))
})
}
// Apply type filter if provided (single value)
if filter.Type != "" {
qb.Where("type", filter.Type)
}
// Apply types filter if provided (multiple values, IN query)
if len(filter.Types) > 0 {
qb.WhereIn("type", filter.Types)
}
// Apply connector filter if provided
if filter.Connector != "" {
qb.Where("connector", filter.Connector)
}
// Apply assistant_id filter if provided
if filter.AssistantID != "" {
qb.Where("assistant_id", filter.AssistantID)
}
// Apply assistantIDs filter if provided
if len(filter.AssistantIDs) > 0 {
qb.WhereIn("assistant_id", filter.AssistantIDs)
}
// Apply mentionable filter if provided
if filter.Mentionable != nil {
qb.Where("mentionable", *filter.Mentionable)
}
// Apply automated filter if provided
if filter.Automated != nil {
qb.Where("automated", *filter.Automated)
}
// Apply built_in filter if provided
if filter.BuiltIn != nil {
qb.Where("built_in", *filter.BuiltIn)
}
// Apply sandbox filter (true = has sandbox config, false = no sandbox config)
// DB JSON columns distinguish between SQL NULL and JSON literal null.
// Dialect-specific: MySQL uses CAST(... AS CHAR), PG uses ::text, SQLite uses CAST(... AS TEXT).
if filter.Sandbox != nil {
notNull, isNull := store.sandboxRawSQL()
if *filter.Sandbox {
qb.WhereNotNull("sandbox").WhereRaw(notNull)
} else {
qb.Where(func(qb query.Query) {
qb.WhereNull("sandbox").OrWhereRaw(isNull)
})
}
}
// Apply custom query filter function (for permission filtering)
if filter.QueryFilter != nil {
qb.Where(filter.QueryFilter)
}
// Set defaults for pagination
if filter.PageSize <= 0 {
filter.PageSize = 20
}
if filter.Page <= 0 {
filter.Page = 1
}
// Get total count
total, err := qb.Clone().Count()
if err != nil {
return nil, err
}
// Calculate pagination
offset := (filter.Page - 1) * filter.PageSize
totalPages := int(math.Ceil(float64(total) / float64(filter.PageSize)))
nextPage := filter.Page + 1
if nextPage > totalPages {
nextPage = 0
}
prevPage := filter.Page - 1
if prevPage < 1 {
prevPage = 0
}
// Apply select fields with security validation (only if fields are explicitly specified)
if len(filter.Select) > 0 {
// ValidateAssistantFields will validate fields against whitelist
sanitized := types.ValidateAssistantFields(filter.Select)
selectFields := make([]interface{}, len(sanitized))
for i, field := range sanitized {
selectFields[i] = field
}
qb.Select(selectFields...)
}
// If no select fields specified, query will return all fields (SELECT *)
// Get paginated results
rows, err := qb.OrderBy("sort", "asc").
OrderBy("updated_at", "desc").
Offset(offset).
Limit(filter.PageSize).
Get()
if err != nil {
return nil, err
}
// Convert rows to types.AssistantModel slice
assistants := make([]*types.AssistantModel, 0, len(rows))
jsonFields := []string{"tags", "options", "prompts", "prompt_presets", "connector_options", "workflow", "sandbox", "kb", "mcp", "placeholder", "locales", "uses", "search", "dependencies"}
for _, row := range rows {
data := row.ToMap()
if data == nil {
continue
}
// Parse JSON fields
store.parseJSONFields(data, jsonFields)
// Convert map to types.AssistantModel using existing helper function
model, err := types.ToAssistantModel(data)
if err != nil {
log.Error("Failed to convert row to types.AssistantModel: %s", err.Error())
continue
}
// Apply i18n translations if locale is provided
if len(locale) > 0 && locale[0] != "" && model != nil {
store.translate(model, model.ID, locale[0])
}
assistants = append(assistants, model)
}
return &types.AssistantList{
Data: assistants,
Page: filter.Page,
PageSize: filter.PageSize,
PageCount: totalPages,
Next: nextPage,
Prev: prevPage,
Total: int(total),
}, nil
}
// GetAssistant retrieves a single assistant by ID
func (store *Xun) GetAssistant(assistantID string, fields []string, locale ...string) (*types.AssistantModel, error) {
qb := store.query.New().
Table(store.getAssistantTable()).
Where("assistant_id", assistantID)
// Apply select fields with security validation
// If no fields specified, use default fields
fieldsToSelect := fields
if len(fieldsToSelect) == 0 {
fieldsToSelect = types.AssistantDefaultFields
}
// ValidateAssistantFields will validate fields against whitelist
sanitized := types.ValidateAssistantFields(fieldsToSelect)
selectFields := make([]interface{}, len(sanitized))
for i, field := range sanitized {
selectFields[i] = field
}
qb.Select(selectFields...)
row, err := qb.First()
if err != nil {
return nil, err
}
if row == nil {
return nil, fmt.Errorf("assistant %s not found", assistantID)
}
data := row.ToMap()
if len(data) == 0 {
return nil, fmt.Errorf("the assistant %s is empty", assistantID)
}
// Parse JSON fields
jsonFields := []string{"tags", "modes", "options", "prompts", "prompt_presets", "connector_options", "workflow", "sandbox", "kb", "db", "mcp", "placeholder", "locales", "uses", "search", "dependencies"}
store.parseJSONFields(data, jsonFields)
// Convert map to types.AssistantModel
model := &types.AssistantModel{
ID: getString(data, "assistant_id"),
Type: getString(data, "type"),
Name: getString(data, "name"),
Avatar: getString(data, "avatar"),
Connector: getString(data, "connector"),
Path: getString(data, "path"),
Source: getString(data, "source"),
BuiltIn: getBool(data, "built_in"),
Sort: getInt(data, "sort"),
Description: getString(data, "description"),
Capabilities: getString(data, "capabilities"),
DefaultMode: getString(data, "default_mode"),
Readonly: getBool(data, "readonly"),
Public: getBool(data, "public"),
Share: getString(data, "share"),
Mentionable: getBool(data, "mentionable"),
Automated: getBool(data, "automated"),
DisableGlobalPrompts: getBool(data, "disable_global_prompts"),
CreatedAt: getInt64(data, "created_at"),
UpdatedAt: getInt64(data, "updated_at"),
YaoCreatedBy: getString(data, "__yao_created_by"),
YaoUpdatedBy: getString(data, "__yao_updated_by"),
YaoTeamID: getString(data, "__yao_team_id"),
YaoTenantID: getString(data, "__yao_tenant_id"),
}
// Handle Tags
if tags, ok := data["tags"].([]interface{}); ok {
model.Tags = make([]string, len(tags))
for i, tag := range tags {
if s, ok := tag.(string); ok {
model.Tags[i] = s
}
}
}
// Handle Modes
if modes, ok := data["modes"].([]interface{}); ok {
model.Modes = make([]string, len(modes))
for i, mode := range modes {
if s, ok := mode.(string); ok {
model.Modes[i] = s
}
}
}
// Handle Options
if options, ok := data["options"].(map[string]interface{}); ok {
model.Options = options
}
// Handle typed fields with conversion
if prompts, has := data["prompts"]; has && prompts != nil {
// Try to unmarshal to []Prompt
raw, err := jsoniter.Marshal(prompts)
if err == nil {
var p []types.Prompt
if err := jsoniter.Unmarshal(raw, &p); err == nil {
model.Prompts = p
}
}
}
if promptPresets, has := data["prompt_presets"]; has && promptPresets != nil {
raw, err := jsoniter.Marshal(promptPresets)
if err == nil {
var pp map[string][]types.Prompt
if err := jsoniter.Unmarshal(raw, &pp); err == nil {
model.PromptPresets = pp
}
}
}
if connectorOptions, has := data["connector_options"]; has && connectorOptions != nil {
raw, err := jsoniter.Marshal(connectorOptions)
if err == nil {
var co types.ConnectorOptions
if err := jsoniter.Unmarshal(raw, &co); err == nil {
model.ConnectorOptions = &co
}
}
}
if kb, has := data["kb"]; has && kb != nil {
kbConverted, err := types.ToKnowledgeBase(kb)
if err == nil {
model.KB = kbConverted
}
}
if db, has := data["db"]; has && db != nil {
dbConverted, err := types.ToDatabase(db)
if err == nil {
model.DB = dbConverted
}
}
if mcp, has := data["mcp"]; has && mcp != nil {
mcpConverted, err := types.ToMCPServers(mcp)
if err == nil {
model.MCP = mcpConverted
}
}
if workflow, has := data["workflow"]; has && workflow != nil {
wf, err := types.ToWorkflow(workflow)
if err == nil {
model.Workflow = wf
}
}
if sandbox, has := data["sandbox"]; has && sandbox != nil {
sb, err := types.ToSandbox(sandbox)
if err == nil {
model.Sandbox = sb
}
}
if placeholder, has := data["placeholder"]; has && placeholder != nil {
raw, err := jsoniter.Marshal(placeholder)
if err == nil {
var ph types.Placeholder
if err := jsoniter.Unmarshal(raw, &ph); err == nil {
model.Placeholder = &ph
}
}
}
if locales, has := data["locales"]; has && locales != nil {
raw, err := jsoniter.Marshal(locales)
if err == nil {
var loc i18n.Map
if err := jsoniter.Unmarshal(raw, &loc); err == nil {
model.Locales = loc
}
}
}
if uses, has := data["uses"]; has && uses != nil {
raw, err := jsoniter.Marshal(uses)
if err == nil {
var u context.Uses
if err := jsoniter.Unmarshal(raw, &u); err == nil {
model.Uses = &u
}
}
}
if search, has := data["search"]; has && search != nil {
raw, err := jsoniter.Marshal(search)
if err == nil {
var s searchTypes.Config
if err := jsoniter.Unmarshal(raw, &s); err == nil {
model.Search = &s
}
}
}
if deps, has := data["dependencies"]; has && deps != nil {
raw, err := jsoniter.Marshal(deps)
if err == nil {
var d map[string]string
if err := jsoniter.Unmarshal(raw, &d); err == nil {
model.Dependencies = d
}
}
}
// Apply i18n translation if locale is provided
if len(locale) > 0 && locale[0] != "" {
store.translate(model, assistantID, locale[0])
}
return model, nil
}
// DeleteAssistants deletes assistants based on filter conditions
func (store *Xun) DeleteAssistants(filter types.AssistantFilter) (int64, error) {
qb := store.query.New().
Table(store.getAssistantTable())
// Apply tag filter if provided
if len(filter.Tags) > 0 {
qb.Where(func(qb query.Query) {
for i, tag := range filter.Tags {
val := store.jsonContainsValue(fmt.Sprintf("%%\"%s\"%%", tag))
if i == 0 {
qb.WhereJSONContains("tags", val)
} else {
qb.OrWhereJSONContains("tags", val)
}
}
})
}
// Apply keyword filter if provided
if filter.Keywords != "" {
kw := fmt.Sprintf("%%%s%%", filter.Keywords)
qb.Where(func(qb query.Query) {
qb.Where("name", "like", kw).
OrWhere("description", "like", kw)
})
}
// Apply connector filter if provided
if filter.Connector != "" {
qb.Where("connector", filter.Connector)
}
// Apply assistant_id filter if provided
if filter.AssistantID != "" {
qb.Where("assistant_id", filter.AssistantID)
}
// Apply assistantIDs filter if provided
if len(filter.AssistantIDs) > 0 {
qb.WhereIn("assistant_id", filter.AssistantIDs)
}
// Apply mentionable filter if provided
if filter.Mentionable != nil {
qb.Where("mentionable", *filter.Mentionable)
}
// Apply automated filter if provided
if filter.Automated != nil {
qb.Where("automated", *filter.Automated)
}
// Apply built_in filter if provided
if filter.BuiltIn != nil {
qb.Where("built_in", *filter.BuiltIn)
}
// Execute delete and return number of deleted records
return qb.Delete()
}
// GetAssistantTags retrieves all unique tags from assistants with filtering
func (store *Xun) GetAssistantTags(filter types.AssistantFilter, locale ...string) ([]types.Tag, error) {
qb := store.query.New().Table(store.getAssistantTable())
// Apply type filter
if len(filter.Types) > 0 {
qb.WhereIn("type", filter.Types)
} else if filter.Type != "" {
qb.Where("type", filter.Type)
}
// Apply custom query filter function (for permission filtering)
if filter.QueryFilter != nil {
qb.Where(filter.QueryFilter)
}
// Apply other filters if provided
if filter.Connector != "" {
qb.Where("connector", filter.Connector)
}
if filter.BuiltIn != nil {
qb.Where("built_in", *filter.BuiltIn)
}
if filter.Mentionable != nil {
qb.Where("mentionable", *filter.Mentionable)
}
if filter.Automated != nil {
qb.Where("automated", *filter.Automated)
}
// Apply keyword filter if provided
if filter.Keywords != "" {
qb.Where(func(qb query.Query) {
qb.Where("name", "like", fmt.Sprintf("%%%s%%", filter.Keywords)).
OrWhere("description", "like", fmt.Sprintf("%%%s%%", filter.Keywords))
})
}
rows, err := qb.Select("tags").GroupBy("tags").Get()
if err != nil {
return nil, err
}
tagSet := map[string]bool{}
for _, row := range rows {
if tags, ok := row["tags"].(string); ok && tags != "" {
var tagList []string
if err := jsoniter.UnmarshalFromString(tags, &tagList); err == nil {
for _, tag := range tagList {
tagSet[tag] = true
}
}
}
}
lang := "en"
if len(locale) > 0 {
lang = locale[0]
}
// Convert map keys to slice
tags := make([]types.Tag, 0, len(tagSet))
for tag := range tagSet {
tags = append(tags, types.Tag{
Value: tag,
Label: i18n.TranslateGlobal(lang, tag).(string),
})
}
return tags, nil
}
// translate applies i18n translation to assistant model fields
func (store *Xun) translate(model *types.AssistantModel, assistantID string, locale string) {
if model == nil {
return
}
// Translate name
if translated := i18n.Translate(assistantID, locale, model.Name); translated != nil {
if s, ok := translated.(string); ok {
model.Name = s
}
}
// Translate description
if translated := i18n.Translate(assistantID, locale, model.Description); translated != nil {
if s, ok := translated.(string); ok {
model.Description = s
}
}
// Translate capabilities
if translated := i18n.Translate(assistantID, locale, model.Capabilities); translated != nil {
if s, ok := translated.(string); ok {
model.Capabilities = s
}
}
// Translate prompts
if model.Prompts != nil {
for i := range model.Prompts {
if translated := i18n.Translate(assistantID, locale, model.Prompts[i].Name); translated != nil {
if s, ok := translated.(string); ok {
model.Prompts[i].Name = s
}
}
if translated := i18n.Translate(assistantID, locale, model.Prompts[i].Content); translated != nil {
if s, ok := translated.(string); ok {
model.Prompts[i].Content = s
}
}
}
}
// Translate placeholder
if model.Placeholder != nil {
if translated := i18n.Translate(assistantID, locale, model.Placeholder.Title); translated != nil {
if s, ok := translated.(string); ok {
model.Placeholder.Title = s
}
}
if translated := i18n.Translate(assistantID, locale, model.Placeholder.Description); translated != nil {
if s, ok := translated.(string); ok {
model.Placeholder.Description = s
}
}
if translated := i18n.Translate(assistantID, locale, model.Placeholder.Prompts); translated != nil {
if prompts, ok := translated.([]string); ok {
model.Placeholder.Prompts = prompts
}
}
}
// Tags are NOT translated — they serve as filter keys and must remain
// in their original (English) form so that filter.tags round-trips
// correctly through the LIKE query on the DB column.
}
// localesLikeSQL returns a dialect-specific raw SQL fragment for LIKE on the locales JSON column.
// PostgreSQL's json type does not support the LIKE operator directly; cast to text is required.
// The keyword is escaped and inlined (no bind parameter) to avoid placeholder incompatibility
// across dialects in WhereRaw (PostgreSQL uses $N, MySQL/SQLite use ?).
func (store *Xun) localesLikeSQL(keyword string) string {
escaped := strings.ReplaceAll(keyword, "'", "''")
escaped = strings.ReplaceAll(escaped, "%", "\\%")
escaped = strings.ReplaceAll(escaped, "_", "\\_")
pattern := "'%" + escaped + "%'"
switch store.getDriver() {
case "postgres":
return fmt.Sprintf(`"locales"::text LIKE %s`, pattern)
case "sqlite3":
return fmt.Sprintf("`locales` LIKE %s", pattern)
default:
return fmt.Sprintf("`locales` LIKE %s", pattern)
}
}
// sandboxRawSQL returns dialect-specific raw SQL fragments for sandbox JSON null detection.
// Returns (notNullExpr, isNullExpr) for filtering sandbox field.
// WhereRaw is used here because this is a JSON literal `null` comparison, not a JSON array
// contains query. Each dialect requires different casting to compare the JSON value as text.
// No bind parameters are needed (pure string comparison), so no placeholder issues.
func (store *Xun) sandboxRawSQL() (string, string) {
switch store.getDriver() {
case "postgres":
return `"sandbox"::text <> 'null'`, `"sandbox"::text = 'null'`
case "sqlite3":
return `CAST(sandbox AS TEXT) <> 'null'`, `CAST(sandbox AS TEXT) = 'null'`
default:
return "CAST(`sandbox` AS CHAR) <> 'null'", "CAST(`sandbox` AS CHAR) = 'null'"
}
}