feat(pgsql): support vector and PostGIS indexes with extensions
* Add handling for pgvector and PostGIS extensions in migration scripts * Implement operator class and storage parameters for vector indexes * Update tests to validate new index behaviors and extension creation
This commit is contained in:
+337
-58
@@ -6,8 +6,10 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
@@ -147,8 +149,8 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
statements = append(statements, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", schema.SQLName()))
|
||||
}
|
||||
|
||||
if schemaRequiresPGTrgm(schema) {
|
||||
statements = append(statements, `CREATE EXTENSION IF NOT EXISTS pg_trgm`)
|
||||
for _, extension := range requiredExtensions(schema) {
|
||||
statements = append(statements, fmt.Sprintf("CREATE EXTENSION IF NOT EXISTS %s", pgsql.QuoteExtensionName(extension)))
|
||||
}
|
||||
|
||||
// Phase 2: Create sequences
|
||||
@@ -271,18 +273,12 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
indexType = "btree"
|
||||
}
|
||||
|
||||
// Build column expressions with operator class support for GIN indexes
|
||||
columnExprs := make([]string, 0, len(index.Columns))
|
||||
for _, colName := range index.Columns {
|
||||
colExpr := colName
|
||||
if col, ok := resolveIndexColumn(table, colName); ok {
|
||||
if strings.EqualFold(indexType, "gin") {
|
||||
if opClass := ginOperatorClassForColumn(col, index.Comment); opClass != "" {
|
||||
colExpr = fmt.Sprintf("%s %s", colName, opClass)
|
||||
}
|
||||
}
|
||||
}
|
||||
columnExprs = append(columnExprs, colExpr)
|
||||
// Build column expressions with operator class support (GIN, pgvector, PostGIS)
|
||||
columnExprs := buildIndexColumnExpressions(table, index, indexType)
|
||||
|
||||
withClause := ""
|
||||
if params := indexStorageParameters(index.Comment); params != "" {
|
||||
withClause = fmt.Sprintf(" WITH (%s)", params)
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
@@ -290,8 +286,8 @@ func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, erro
|
||||
whereClause = fmt.Sprintf(" WHERE %s", index.Where)
|
||||
}
|
||||
|
||||
stmt := fmt.Sprintf("CREATE %sINDEX IF NOT EXISTS %s ON %s USING %s (%s)%s",
|
||||
uniqueStr, quoteIdentifier(index.Name), w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), whereClause)
|
||||
stmt := fmt.Sprintf("CREATE %sINDEX IF NOT EXISTS %s ON %s USING %s (%s)%s%s",
|
||||
uniqueStr, quoteIdentifier(index.Name), w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), withClause, whereClause)
|
||||
statements = append(statements, stmt)
|
||||
}
|
||||
}
|
||||
@@ -819,11 +815,14 @@ func (w *Writer) writeCreateSchema(schema *models.Schema) error {
|
||||
}
|
||||
|
||||
func (w *Writer) writeRequiredExtensions(schema *models.Schema) error {
|
||||
if !schemaRequiresPGTrgm(schema) {
|
||||
extensions := requiredExtensions(schema)
|
||||
if len(extensions) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintln(w.writer, "CREATE EXTENSION IF NOT EXISTS pg_trgm;")
|
||||
for _, extension := range extensions {
|
||||
fmt.Fprintf(w.writer, "CREATE EXTENSION IF NOT EXISTS %s;\n", pgsql.QuoteExtensionName(extension))
|
||||
}
|
||||
fmt.Fprintln(w.writer)
|
||||
return nil
|
||||
}
|
||||
@@ -1063,21 +1062,13 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
|
||||
indexName = fmt.Sprintf("%s_%s_%s", indexType, table.SQLName(), strings.ToLower(columnSuffix))
|
||||
}
|
||||
|
||||
// Build column list with operator class support for GIN indexes
|
||||
columnExprs := make([]string, 0, len(index.Columns))
|
||||
for _, colName := range index.Columns {
|
||||
if col, ok := resolveIndexColumn(table, colName); ok {
|
||||
colExpr := col.SQLName()
|
||||
if strings.EqualFold(index.Type, "gin") {
|
||||
opClass := ginOperatorClassForColumn(col, index.Comment)
|
||||
if opClass != "" {
|
||||
colExpr = fmt.Sprintf("%s %s", col.SQLName(), opClass)
|
||||
}
|
||||
}
|
||||
columnExprs = append(columnExprs, colExpr)
|
||||
}
|
||||
indexType := index.Type
|
||||
if indexType == "" {
|
||||
indexType = "btree"
|
||||
}
|
||||
|
||||
// Build column list with operator class support (GIN, pgvector, PostGIS)
|
||||
columnExprs := buildIndexColumnExpressionsFiltered(table, index, indexType, true)
|
||||
if len(columnExprs) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -1087,9 +1078,9 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
|
||||
unique = "UNIQUE "
|
||||
}
|
||||
|
||||
indexType := index.Type
|
||||
if indexType == "" {
|
||||
indexType = "btree"
|
||||
withClause := ""
|
||||
if params := indexStorageParameters(index.Comment); params != "" {
|
||||
withClause = fmt.Sprintf(" WITH (%s)", params)
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
@@ -1104,8 +1095,8 @@ func (w *Writer) writeIndexes(schema *models.Schema) error {
|
||||
|
||||
fmt.Fprintf(w.writer, "CREATE %sINDEX %sIF NOT EXISTS %s\n",
|
||||
unique, concurrently, indexName)
|
||||
fmt.Fprintf(w.writer, " ON %s USING %s (%s)%s;\n\n",
|
||||
w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), whereClause)
|
||||
fmt.Fprintf(w.writer, " ON %s USING %s (%s)%s%s;\n\n",
|
||||
w.qualTable(schema.SQLName(), table.SQLName()), indexType, strings.Join(columnExprs, ", "), withClause, whereClause)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1483,7 +1474,69 @@ func isTextTypeWithoutLength(colType string) bool {
|
||||
return strings.EqualFold(colType, "text")
|
||||
}
|
||||
|
||||
func ginOperatorClassForColumn(col *models.Column, comment string) string {
|
||||
// vectorOperatorClasses maps pgvector operator classes to the column base type they
|
||||
// apply to. pgvector defines no default operator class, so an hnsw/ivfflat index must
|
||||
// always name one explicitly.
|
||||
var vectorOperatorClasses = map[string]string{
|
||||
"vector_l2_ops": "vector",
|
||||
"vector_ip_ops": "vector",
|
||||
"vector_cosine_ops": "vector",
|
||||
"vector_l1_ops": "vector",
|
||||
"halfvec_l2_ops": "halfvec",
|
||||
"halfvec_ip_ops": "halfvec",
|
||||
"halfvec_cosine_ops": "halfvec",
|
||||
"halfvec_l1_ops": "halfvec",
|
||||
"sparsevec_l2_ops": "sparsevec",
|
||||
"sparsevec_ip_ops": "sparsevec",
|
||||
"sparsevec_cosine_ops": "sparsevec",
|
||||
"sparsevec_l1_ops": "sparsevec",
|
||||
"bit_hamming_ops": "bit",
|
||||
"bit_jaccard_ops": "bit",
|
||||
}
|
||||
|
||||
// defaultVectorOperatorClasses is the operator class used for an hnsw/ivfflat index when
|
||||
// the index comment does not request one. Cosine distance is the common default for
|
||||
// embedding columns; override it with an "opclass" hint in the index comment.
|
||||
var defaultVectorOperatorClasses = map[string]string{
|
||||
"vector": "vector_cosine_ops",
|
||||
"halfvec": "halfvec_cosine_ops",
|
||||
"sparsevec": "sparsevec_cosine_ops",
|
||||
"bit": "bit_hamming_ops",
|
||||
}
|
||||
|
||||
// spatialOperatorClasses are the PostGIS operator classes recognized in index comments.
|
||||
// PostGIS installs default operator classes for gist/spgist/brin, so these are only
|
||||
// emitted when explicitly requested (e.g. the 3D/nD variants).
|
||||
var spatialOperatorClasses = map[string]bool{
|
||||
"gist_geometry_ops_2d": true,
|
||||
"gist_geometry_ops_nd": true,
|
||||
"gist_geography_ops": true,
|
||||
"spgist_geometry_ops_2d": true,
|
||||
"spgist_geometry_ops_3d": true,
|
||||
"spgist_geometry_ops_nd": true,
|
||||
"brin_geometry_inclusion_ops_2d": true,
|
||||
"brin_geometry_inclusion_ops_3d": true,
|
||||
"brin_geometry_inclusion_ops_4d": true,
|
||||
"brin_geography_inclusion_ops_2d": true,
|
||||
"btree_geometry_ops": true,
|
||||
"btree_geography_ops": true,
|
||||
}
|
||||
|
||||
// isVectorIndexMethod reports whether the access method indexes pgvector types, which
|
||||
// covers both pgvector itself (hnsw, ivfflat) and VectorChord (vchordrq, vchordg).
|
||||
func isVectorIndexMethod(method string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(method)) {
|
||||
case "hnsw", "ivfflat", "vchordrq", "vchordg":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// indexOperatorClassForColumn returns the operator class to emit for a column in an index
|
||||
// of the given access method, honouring an explicit request from the index comment when it
|
||||
// is compatible with the column type.
|
||||
func indexOperatorClassForColumn(col *models.Column, indexType, comment string) string {
|
||||
if col == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -1492,26 +1545,53 @@ func ginOperatorClassForColumn(col *models.Column, comment string) string {
|
||||
baseType := pgsql.CanonicalizeBaseType(pgsql.ExtractBaseTypeLower(sqlType))
|
||||
isArray := pgsql.IsArrayType(sqlType)
|
||||
requested := extractOperatorClass(comment)
|
||||
|
||||
if requested != "" && ginOperatorClassCompatible(baseType, isArray, requested) {
|
||||
return requested
|
||||
method := strings.ToLower(strings.TrimSpace(indexType))
|
||||
if method == "" {
|
||||
method = "btree"
|
||||
}
|
||||
|
||||
if isArray {
|
||||
return "array_ops"
|
||||
if requested != "" && operatorClassCompatible(method, baseType, isArray, requested) {
|
||||
return requested
|
||||
}
|
||||
|
||||
switch {
|
||||
case isTextGinBaseType(baseType):
|
||||
return "gin_trgm_ops"
|
||||
case baseType == "jsonb":
|
||||
return "jsonb_ops"
|
||||
case method == "gin":
|
||||
if isArray {
|
||||
return "array_ops"
|
||||
}
|
||||
switch {
|
||||
case isTextGinBaseType(baseType):
|
||||
return "gin_trgm_ops"
|
||||
case baseType == "jsonb":
|
||||
return "jsonb_ops"
|
||||
default:
|
||||
return requested
|
||||
}
|
||||
case isVectorIndexMethod(method):
|
||||
if isArray {
|
||||
return ""
|
||||
}
|
||||
return defaultVectorOperatorClasses[baseType]
|
||||
default:
|
||||
return requested
|
||||
// gist/spgist/brin/btree have default operator classes (PostGIS included),
|
||||
// so nothing is emitted unless the comment requested a compatible class.
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func ginOperatorClassCompatible(baseType string, isArray bool, opClass string) bool {
|
||||
// ginOperatorClassForColumn is the GIN-specific form of indexOperatorClassForColumn.
|
||||
func ginOperatorClassForColumn(col *models.Column, comment string) string {
|
||||
return indexOperatorClassForColumn(col, "gin", comment)
|
||||
}
|
||||
|
||||
func operatorClassCompatible(method, baseType string, isArray bool, opClass string) bool {
|
||||
if vectorType, ok := vectorOperatorClasses[opClass]; ok {
|
||||
return !isArray && baseType == vectorType && isVectorIndexMethod(method)
|
||||
}
|
||||
if spatialOperatorClasses[opClass] {
|
||||
return !isArray && pgsql.IsSpatialType(baseType)
|
||||
}
|
||||
|
||||
switch opClass {
|
||||
case "gin_trgm_ops", "gin_bigm_ops":
|
||||
return !isArray && isTextGinBaseType(baseType)
|
||||
@@ -1524,6 +1604,10 @@ func ginOperatorClassCompatible(baseType string, isArray bool, opClass string) b
|
||||
}
|
||||
}
|
||||
|
||||
func ginOperatorClassCompatible(baseType string, isArray bool, opClass string) bool {
|
||||
return operatorClassCompatible("gin", baseType, isArray, opClass)
|
||||
}
|
||||
|
||||
func isTextGinBaseType(baseType string) bool {
|
||||
switch baseType {
|
||||
case "text", "varchar", "character varying", "char", "character", "string", "citext", "bpchar":
|
||||
@@ -1533,29 +1617,188 @@ func isTextGinBaseType(baseType string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func schemaRequiresPGTrgm(schema *models.Schema) bool {
|
||||
// requiredExtensions returns the PostgreSQL extensions a schema depends on, ordered so
|
||||
// that dependencies are created first (postgis before postgis_topology, vector before
|
||||
// vchord). Extensions are detected from column types, index access methods, resolved
|
||||
// operator classes, and function calls in defaults, check constraints, partial index
|
||||
// predicates and view definitions. Extensions that leave no trace in the model (pg_cron,
|
||||
// timescaledb, postgres_fdw, …) can be declared in schema.Metadata["extensions"].
|
||||
func requiredExtensions(schema *models.Schema) []string {
|
||||
if schema == nil {
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
|
||||
required := make(map[string]bool)
|
||||
add := func(names ...string) {
|
||||
for _, name := range names {
|
||||
if name != "" {
|
||||
required[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
add(declaredExtensions(schema)...)
|
||||
|
||||
for _, view := range schema.Views {
|
||||
if view == nil {
|
||||
continue
|
||||
}
|
||||
add(pgsql.ExtensionsForExpression(view.Definition)...)
|
||||
}
|
||||
|
||||
for _, table := range schema.Tables {
|
||||
if table == nil {
|
||||
continue
|
||||
}
|
||||
for _, index := range table.Indexes {
|
||||
if index == nil || !strings.EqualFold(index.Type, "gin") {
|
||||
|
||||
for _, col := range table.Columns {
|
||||
if col == nil {
|
||||
continue
|
||||
}
|
||||
add(pgsql.TypeExtension(effectiveColumnSQLType(col)))
|
||||
if def, ok := col.Default.(string); ok {
|
||||
add(pgsql.ExtensionsForExpression(def)...)
|
||||
}
|
||||
}
|
||||
|
||||
for _, constraint := range table.Constraints {
|
||||
if constraint == nil {
|
||||
continue
|
||||
}
|
||||
add(pgsql.ExtensionsForExpression(constraint.Expression)...)
|
||||
}
|
||||
|
||||
for _, index := range table.Indexes {
|
||||
if index == nil {
|
||||
continue
|
||||
}
|
||||
add(pgsql.IndexMethodExtension(index.Type))
|
||||
add(pgsql.ExtensionsForExpression(index.Where)...)
|
||||
|
||||
for _, colName := range index.Columns {
|
||||
col, ok := resolveIndexColumn(table, colName)
|
||||
if !ok || col == nil {
|
||||
continue
|
||||
}
|
||||
if ginOperatorClassForColumn(col, index.Comment) == "gin_trgm_ops" {
|
||||
return true
|
||||
}
|
||||
opClass := indexOperatorClassForColumn(col, index.Type, index.Comment)
|
||||
add(pgsql.OperatorClassExtension(opClass))
|
||||
add(btreeCompanionExtension(index.Type, col, opClass))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extensions := make([]string, 0, len(required))
|
||||
for ext := range required {
|
||||
extensions = append(extensions, ext)
|
||||
}
|
||||
|
||||
// Pull in dependencies, so a declared postgis_topology also creates postgis.
|
||||
for i := 0; i < len(extensions); i++ {
|
||||
for _, dependency := range pgsql.ExtensionDependencies(extensions[i]) {
|
||||
if !required[dependency] {
|
||||
required[dependency] = true
|
||||
extensions = append(extensions, dependency)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pgsql.SortExtensions(extensions)
|
||||
}
|
||||
|
||||
// declaredExtensions reads schema.Metadata["extensions"], which accepts either a list or a
|
||||
// comma-separated string. Unknown names are kept: the metadata is an explicit instruction.
|
||||
func declaredExtensions(schema *models.Schema) []string {
|
||||
value, ok := schema.Metadata["extensions"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
var names []string
|
||||
switch declared := value.(type) {
|
||||
case string:
|
||||
names = strings.Split(declared, ",")
|
||||
case []string:
|
||||
names = declared
|
||||
case []any:
|
||||
for _, item := range declared {
|
||||
if name, ok := item.(string); ok {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
cleaned := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
cleaned = append(cleaned, name)
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// btreeCompanionExtension returns btree_gin or btree_gist when a GIN/GiST index covers a
|
||||
// scalar type that neither access method has a built-in operator class for. Without the
|
||||
// companion extension PostgreSQL rejects the CREATE INDEX outright.
|
||||
func btreeCompanionExtension(indexType string, col *models.Column, opClass string) string {
|
||||
if opClass != "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
method := strings.ToLower(strings.TrimSpace(indexType))
|
||||
if method != "gin" && method != "gist" {
|
||||
return ""
|
||||
}
|
||||
|
||||
sqlType := effectiveColumnSQLType(col)
|
||||
if pgsql.IsArrayType(sqlType) {
|
||||
return ""
|
||||
}
|
||||
|
||||
baseType := pgsql.CanonicalizeBaseType(pgsql.ExtractBaseTypeLower(sqlType))
|
||||
if pgsql.TypeExtension(baseType) != "" {
|
||||
// Extension types (geometry, vector, citext, …) ship their own operator classes.
|
||||
return ""
|
||||
}
|
||||
|
||||
if method == "gin" {
|
||||
if nativeGinBaseType(baseType) {
|
||||
return ""
|
||||
}
|
||||
return "btree_gin"
|
||||
}
|
||||
if nativeGistBaseType(baseType) {
|
||||
return ""
|
||||
}
|
||||
return "btree_gist"
|
||||
}
|
||||
|
||||
// nativeGinBaseType reports whether core PostgreSQL provides a GIN operator class.
|
||||
func nativeGinBaseType(baseType string) bool {
|
||||
switch baseType {
|
||||
case "jsonb", "json", "tsvector", "tsquery":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// nativeGistBaseType reports whether core PostgreSQL provides a GiST operator class.
|
||||
func nativeGistBaseType(baseType string) bool {
|
||||
switch baseType {
|
||||
case "tsvector", "tsquery", "point", "box", "circle", "polygon", "line", "lseg", "path", "inet", "cidr":
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(baseType, "range") || strings.HasSuffix(baseType, "multirange")
|
||||
}
|
||||
|
||||
func schemaRequiresPGTrgm(schema *models.Schema) bool {
|
||||
for _, ext := range requiredExtensions(schema) {
|
||||
if ext == "pg_trgm" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1642,14 +1885,21 @@ func formatStringList(items []string) string {
|
||||
|
||||
// extractOperatorClass extracts operator class from index comment/note
|
||||
// Looks for common operator classes like gin_trgm_ops, gist_trgm_ops, etc.
|
||||
// explicitOperatorClassPattern matches an "opclass=<name>" hint, the form the PostgreSQL
|
||||
// reader uses to carry an index's operator class through the model.
|
||||
var explicitOperatorClassPattern = regexp.MustCompile(`(?i)\bopclass\s*=\s*([a-z_][a-z0-9_]*)\b`)
|
||||
|
||||
func extractOperatorClass(comment string) string {
|
||||
if comment == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lowerComment := strings.ToLower(comment)
|
||||
// Common GIN/GiST operator classes
|
||||
opClasses := []string{"gin_trgm_ops", "gist_trgm_ops", "gin_bigm_ops", "jsonb_ops", "jsonb_path_ops", "array_ops"}
|
||||
for _, op := range opClasses {
|
||||
if matches := explicitOperatorClassPattern.FindStringSubmatch(lowerComment); len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
for _, op := range knownOperatorClasses() {
|
||||
if strings.Contains(lowerComment, op) {
|
||||
return op
|
||||
}
|
||||
@@ -1657,6 +1907,35 @@ func extractOperatorClass(comment string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// knownOperatorClasses lists every operator class recognized in an index comment,
|
||||
// longest name first so that e.g. gist_geometry_ops_nd wins over a shorter prefix.
|
||||
var knownOperatorClasses = sync.OnceValue(func() []string {
|
||||
names := []string{"gin_trgm_ops", "gist_trgm_ops", "gin_bigm_ops", "jsonb_ops", "jsonb_path_ops", "array_ops"}
|
||||
for name := range vectorOperatorClasses {
|
||||
names = append(names, name)
|
||||
}
|
||||
for name := range spatialOperatorClasses {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Slice(names, func(i, j int) bool {
|
||||
if len(names[i]) != len(names[j]) {
|
||||
return len(names[i]) > len(names[j])
|
||||
}
|
||||
return names[i] < names[j]
|
||||
})
|
||||
return names
|
||||
})
|
||||
|
||||
// indexStorageParameters extracts access-method storage parameters from an index comment.
|
||||
// Only well-formed "key = value" pairs are kept, so comment prose cannot leak into DDL.
|
||||
// Example: "opclass=vector_cosine_ops with (m=16, ef_construction=64)" -> "m = 16, ef_construction = 64".
|
||||
func indexStorageParameters(comment string) string {
|
||||
if comment == "" {
|
||||
return ""
|
||||
}
|
||||
return pgsql.FormatStorageParameters(pgsql.ExtractWithClause(comment))
|
||||
}
|
||||
|
||||
// escapeQuote escapes single quotes in strings for SQL
|
||||
func escapeQuote(s string) string {
|
||||
return strings.ReplaceAll(s, "'", "''")
|
||||
|
||||
Reference in New Issue
Block a user