Files
wehub-resource-sync 5357c39144
Fuzzer / Run Fuzzer (push) Has been cancelled
Race tests / Go race tests (ubuntu-22.04) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:01:40 +08:00

565 lines
21 KiB
Go

// Copyright 2019 Dolthub, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package schcmds
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/fatih/color"
"github.com/dolthub/dolt/go/cmd/dolt/cli"
"github.com/dolthub/dolt/go/cmd/dolt/commands"
"github.com/dolthub/dolt/go/cmd/dolt/errhand"
"github.com/dolthub/dolt/go/libraries/doltcore/doltdb"
"github.com/dolthub/dolt/go/libraries/doltcore/doltdb/durable"
"github.com/dolthub/dolt/go/libraries/doltcore/env"
"github.com/dolthub/dolt/go/libraries/doltcore/env/actions"
"github.com/dolthub/dolt/go/libraries/doltcore/rowconv"
"github.com/dolthub/dolt/go/libraries/doltcore/schema"
"github.com/dolthub/dolt/go/libraries/doltcore/sqle"
"github.com/dolthub/dolt/go/libraries/doltcore/table"
"github.com/dolthub/dolt/go/libraries/doltcore/table/editor"
"github.com/dolthub/dolt/go/libraries/doltcore/table/untyped/csv"
"github.com/dolthub/dolt/go/libraries/utils/argparser"
"github.com/dolthub/dolt/go/libraries/utils/funcitr"
"github.com/dolthub/dolt/go/libraries/utils/set"
"github.com/dolthub/dolt/go/store/types"
eventsapi "github.com/dolthub/eventsapi_schema/dolt/services/eventsapi/v1alpha1"
)
const (
createFlag = "create"
updateFlag = "update"
replaceFlag = "replace"
dryRunFlag = "dry-run"
fileTypeParam = "file-type"
pksParam = "pks"
mappingParam = "map"
floatThresholdParam = "float-threshold"
keepTypesParam = "keep-types"
delimParam = "delim"
)
var MappingFileHelp = "A mapping file is json in the format:" + `
{
"source_field_name":"dest_field_name"
...
}
where source_field_name is the name of a field in the file being imported and dest_field_name is the name of a field in the table being imported to.
`
var schImportDocs = cli.CommandDocumentationContent{
ShortDesc: "Creates or updates a table by inferring a schema from a file containing sample data.",
LongDesc: `If {{.EmphasisLeft}}--create | -c{{.EmphasisRight}} is given the operation will create {{.LessThan}}table{{.GreaterThan}} with a schema that it infers from the supplied file. One or more primary key columns must be specified using the {{.EmphasisLeft}}--pks{{.EmphasisRight}} parameter.
If {{.EmphasisLeft}}--update | -u{{.EmphasisRight}} is given the operation will update {{.LessThan}}table{{.GreaterThan}} any additional columns, or change the types of columns based on the file supplied. If the {{.EmphasisLeft}}--keep-types{{.EmphasisRight}} parameter is supplied then the types for existing columns will not be modified, even if they differ from what is in the supplied file.
If {{.EmphasisLeft}}--replace | -r{{.EmphasisRight}} is given the operation will replace {{.LessThan}}table{{.GreaterThan}} with a new, empty table which has a schema inferred from the supplied file but columns tags will be maintained across schemas. {{.EmphasisLeft}}--keep-types{{.EmphasisRight}} can also be supplied here to guarantee that types are the same in the file and in the pre-existing table.
A mapping file can be used to map fields between the file being imported and the table's schema being inferred. This can be used when creating a new table, or updating or replacing an existing table.
` + MappingFileHelp + `
In create, update, and replace scenarios the file's extension is used to infer the type of the file. If a file does not have the expected extension then the {{.EmphasisLeft}}--file-type{{.EmphasisRight}} parameter should be used to explicitly define the format of the file in one of the supported formats (Currently only csv is supported). For files separated by a delimiter other than a ',', the --delim parameter can be used to specify a delimiter.
If the parameter {{.EmphasisLeft}}--dry-run{{.EmphasisRight}} is supplied a sql statement will be generated showing what would be executed if this were run without the --dry-run flag
{{.EmphasisLeft}}--float-threshold{{.EmphasisRight}} is the threshold at which a string representing a floating point number should be interpreted as a float versus an int. If FloatThreshold is 0.0 then any number with a decimal point will be interpreted as a float (such as 0.0, 1.0, etc). If FloatThreshold is 1.0 then any number with a decimal point will be converted to an int (0.5 will be the int 0, 1.99 will be the int 1, etc. If the FloatThreshold is 0.001 then numbers with a fractional component greater than or equal to 0.001 will be treated as a float (1.0 would be an int, 1.0009 would be an int, 1.001 would be a float, 1.1 would be a float, etc)
`,
Synopsis: []string{
`[--create|--replace] [--force] [--dry-run] [--lower|--upper] [--keep-types] [--file-type <type>] [--float-threshold] [--map {{.LessThan}}mapping-file{{.GreaterThan}}] [--delim {{.LessThan}}delimiter{{.GreaterThan}}]--pks {{.LessThan}}field{{.GreaterThan}},... {{.LessThan}}table{{.GreaterThan}} {{.LessThan}}file{{.GreaterThan}}`,
},
}
type SchImportOp int
const (
CreateOp SchImportOp = iota
UpdateOp
ReplaceOp
)
type importOptions struct {
op SchImportOp
fileName string
fileType string
delim string
tableName string
existingSch schema.Schema
PkCols []string
keepTypes bool
colMapper rowconv.NameMapper
floatThreshold float64
}
func (im *importOptions) ColNameMapper() rowconv.NameMapper {
return im.colMapper
}
func (im *importOptions) FloatThreshold() float64 {
return im.floatThreshold
}
type ImportCmd struct{}
// Name is returns the name of the Dolt cli command. This is what is used on the command line to invoke the command
func (cmd ImportCmd) Name() string {
return "import"
}
// Description returns a description of the command
func (cmd ImportCmd) Description() string {
return "Creates a new table with an inferred schema."
}
// EventType returns the type of the event to log
func (cmd ImportCmd) EventType() eventsapi.ClientEventType {
return eventsapi.ClientEventType_SCHEMA
}
func (cmd ImportCmd) Docs() *cli.CommandDocumentation {
ap := cmd.ArgParser()
return cli.NewCommandDocumentation(schImportDocs, ap)
}
func (cmd ImportCmd) ArgParser() *argparser.ArgParser {
ap := argparser.NewArgParserWithMaxArgs(cmd.Name(), 2)
ap.ArgListHelp = append(ap.ArgListHelp, [2]string{"table", "Name of the table to be created."})
ap.ArgListHelp = append(ap.ArgListHelp, [2]string{"file", "The file being used to infer the schema."})
ap.SupportsFlag(createFlag, "c", "Create a table with the schema inferred from the {{.LessThan}}file{{.GreaterThan}} provided.")
ap.SupportsFlag(updateFlag, "u", "Update a table to match the inferred schema of the {{.LessThan}}file{{.GreaterThan}} provided. All previous data will be lost.")
ap.SupportsFlag(replaceFlag, "r", "Replace a table with a new schema that has the inferred schema from the {{.LessThan}}file{{.GreaterThan}} provided. All previous data will be lost.")
ap.SupportsFlag(dryRunFlag, "", "Print the sql statement that would be run if executed without the flag.")
ap.SupportsFlag(keepTypesParam, "", "When a column already exists in the table, and it's also in the {{.LessThan}}file{{.GreaterThan}} provided, use the type from the table.")
ap.SupportsString(fileTypeParam, "", "type", "Explicitly define the type of the file if it can't be inferred from the file extension.")
ap.SupportsString(pksParam, "", "comma-separated-col-names", "List of columns used as the primary key cols. Order of the columns will determine sort order.")
ap.SupportsString(mappingParam, "m", "mapping-file", "A file that can map a column name in {{.LessThan}}file{{.GreaterThan}} to a new value.")
ap.SupportsString(floatThresholdParam, "", "float", "Minimum value at which the fractional component of a value must exceed in order to be considered a float.")
ap.SupportsString(delimParam, "", "delimiter", "Specify a delimiter for a csv style file with a non-comma delimiter.")
return ap
}
// Exec implements the import schema command that will take a file and infer its schema, and then create a table matching that schema.
// Exec executes the command
func (cmd ImportCmd) Exec(ctx context.Context, commandStr string, args []string, dEnv *env.DoltEnv, cliCtx cli.CliContext) int {
ap := cmd.ArgParser()
help, usage := cli.HelpAndUsagePrinters(cli.CommandDocsForCommandString(commandStr, schImportDocs, ap))
apr := cli.ParseArgsOrDie(ap, args, help)
if apr.NArg() != 2 {
usage()
return 1
}
return commands.HandleVErrAndExitCode(importSchema(ctx, dEnv, apr), usage)
}
func getSchemaImportArgs(ctx context.Context, apr *argparser.ArgParseResults, dEnv *env.DoltEnv, root doltdb.RootValue) (*importOptions, errhand.VerboseError) {
tblName := apr.Arg(0)
fileName := apr.Arg(1)
fileExists, _ := dEnv.FS.Exists(fileName)
if !fileExists {
return nil, errhand.BuildDError("error: file '%s' not found.", fileName).Build()
}
if err := ValidateTableNameForCreate(tblName); err != nil {
return nil, err
}
flags := apr.ContainsMany(createFlag, updateFlag, replaceFlag)
if len(flags) == 0 {
return nil, errhand.BuildDError("error: missing required parameter.").AddDetails("Must provide exactly one of the operation flags '--create', or '--replace'").SetPrintUsage().Build()
} else if len(flags) > 1 {
return nil, errhand.BuildDError("error: multiple operations supplied").AddDetails("Only one of the flags '--create', '--update', or '--replace' may be provided").SetPrintUsage().Build()
}
var op SchImportOp
switch flags[0] {
case createFlag:
op = CreateOp
case updateFlag:
op = UpdateOp
case replaceFlag:
op = ReplaceOp
}
if apr.Contains(keepTypesParam) && op == CreateOp {
return nil, errhand.BuildDError("error: parameter keep-types not supported for create operations").AddDetails("keep-types parameter is used to keep the existing column types as is without modification.").Build()
}
tbl, tblExists, err := root.GetTable(ctx, doltdb.TableName{Name: tblName})
if err != nil {
return nil, errhand.BuildDError("error: failed to read from database.").AddCause(err).Build()
} else if tblExists && op == CreateOp {
return nil, errhand.BuildDError("error: failed to create table.").AddDetails("A table named '%s' already exists.", tblName).AddDetails("Use --replace or --update instead of --create.").Build()
}
if op != CreateOp {
rows, err := tbl.GetRowData(ctx)
if err != nil {
return nil, errhand.VerboseErrorFromError(err)
}
rowCnt, err := rows.Count()
if err != nil {
return nil, errhand.VerboseErrorFromError(err)
}
if rowCnt > 0 {
return nil, errhand.BuildDError("This operation will delete all row data. If this is your intent, "+
"run dolt sql -q 'delete from %s' to delete all row data, then re-run this command.", tblName).Build()
}
}
var existingSch schema.Schema = schema.EmptySchema
if tblExists {
existingSch, err = tbl.GetSchema(ctx)
if err != nil {
return nil, errhand.BuildDError("error: failed to read schema from '%s'", tblName).AddCause(err).Build()
}
}
val, pksOK := apr.GetValue(pksParam)
pks := funcitr.MapStrings(strings.Split(val, ","), strings.TrimSpace)
pks = funcitr.FilterStrings(pks, func(s string) bool { return s != "" })
if !pksOK {
return nil, errhand.BuildDError("error: missing required parameter pks").SetPrintUsage().Build()
}
if len(pks) == 0 {
return nil, errhand.BuildDError("error: no valid columns provided in --pks argument").Build()
}
mappingFile := apr.GetValueOrDefault(mappingParam, "")
colMapper, err := rowconv.NameMapperFromFile(mappingFile, dEnv.FS)
if err != nil {
return nil, errhand.VerboseErrorFromError(err)
}
floatThresholdStr := apr.GetValueOrDefault(floatThresholdParam, "0.0")
floatThreshold, err := strconv.ParseFloat(floatThresholdStr, 64)
if err != nil {
return nil, errhand.BuildDError("error: '%s' is not a valid float in the range 0.0 (all floats) to 1.0 (no floats)", floatThresholdStr).SetPrintUsage().Build()
}
return &importOptions{
op: op,
fileName: fileName,
fileType: apr.GetValueOrDefault(fileTypeParam, filepath.Ext(fileName)),
delim: apr.GetValueOrDefault(delimParam, ","),
tableName: tblName,
existingSch: existingSch,
PkCols: pks,
keepTypes: apr.Contains(keepTypesParam),
colMapper: colMapper,
floatThreshold: floatThreshold,
}, nil
}
func importSchema(ctx context.Context, dEnv *env.DoltEnv, apr *argparser.ArgParseResults) errhand.VerboseError {
root, verr := commands.GetWorkingWithVErr(dEnv)
if verr != nil {
return verr
}
impArgs, verr := getSchemaImportArgs(ctx, apr, dEnv, root)
if verr != nil {
return verr
}
sch, verr := inferSchemaFromFile(ctx, dEnv.DoltDB(ctx).ValueReadWriter().Format(), impArgs, root)
if verr != nil {
return verr
}
tblName := impArgs.tableName
root, verr = putEmptyTableWithSchema(ctx, tblName, root, sch)
if verr != nil {
return verr
}
sqlDb := sqle.NewUserSpaceDatabase(root, editor.Options{})
sqlCtx, engine, _ := sqle.PrepareCreateTableStmt(ctx, sqlDb)
stmt, err := sqle.GetCreateTableStmt(sqlCtx, engine, tblName)
if err != nil {
return errhand.VerboseErrorFromError(err)
}
cli.Println(stmt)
if !apr.Contains(dryRunFlag) {
err = dEnv.UpdateWorkingRoot(ctx, root)
if err != nil {
return errhand.BuildDError("error: failed to update the working set.").AddCause(err).Build()
}
cli.PrintErrln(color.CyanString("Created table successfully."))
}
return nil
}
func putEmptyTableWithSchema(ctx context.Context, tblName string, root doltdb.RootValue, sch schema.Schema) (doltdb.RootValue, errhand.VerboseError) {
tbl, tblExists, err := root.GetTable(ctx, doltdb.TableName{Name: tblName})
if err != nil {
return nil, errhand.BuildDError("error: failed to get table.").AddCause(err).Build()
}
empty, err := durable.NewEmptyPrimaryIndex(ctx, root.VRW(), root.NodeStore(), sch)
if err != nil {
return nil, errhand.BuildDError("error: failed to get table.").AddCause(err).Build()
}
var indexSet durable.IndexSet
if tblExists {
indexSet, err = tbl.GetIndexSet(ctx)
if err != nil {
return nil, errhand.BuildDError("error: failed to create table.").AddCause(err).Build()
}
} else {
indexSet, err = durable.NewIndexSetWithEmptyIndexes(ctx, root.VRW(), root.NodeStore(), sch)
if err != nil {
return nil, errhand.BuildDError("error: failed to get table.").AddCause(err).Build()
}
}
tbl, err = doltdb.NewTable(ctx, root.VRW(), root.NodeStore(), sch, empty, indexSet, nil)
if err != nil {
return nil, errhand.BuildDError("error: failed to get table.").AddCause(err).Build()
}
root, err = root.PutTable(ctx, doltdb.TableName{Name: tblName}, tbl)
if err != nil {
return nil, errhand.BuildDError("error: failed to add table.").AddCause(err).Build()
}
return root, nil
}
func inferSchemaFromFile(ctx context.Context, nbf *types.NomsBinFormat, impOpts *importOptions, root doltdb.RootValue) (schema.Schema, errhand.VerboseError) {
if impOpts.fileType[0] == '.' {
impOpts.fileType = impOpts.fileType[1:]
}
var rd table.ReadCloser
csvInfo := csv.NewCSVInfo().SetDelim(",")
switch impOpts.fileType {
case "csv":
if impOpts.delim != "" {
csvInfo.SetDelim(impOpts.delim)
}
case "psv":
csvInfo.SetDelim("|")
default:
return nil, errhand.BuildDError("error: unsupported file type '%s'", impOpts.fileType).Build()
}
f, err := os.Open(impOpts.fileName)
if err != nil {
return nil, errhand.BuildDError("error: failed to open '%s'", impOpts.fileName).Build()
}
defer f.Close()
rd, err = csv.NewCSVReader(nbf, f, csvInfo)
if err != nil {
return nil, errhand.BuildDError("error: failed to create a CSVReader.").AddCause(err).Build()
}
defer rd.Close(ctx)
infCols, err := actions.InferColumnTypesFromTableReader(ctx, rd, impOpts)
if err != nil {
return nil, errhand.BuildDError("error: failed to infer schema").AddCause(err).Build()
}
return CombineColCollections(ctx, root, infCols, impOpts)
}
func CombineColCollections(ctx context.Context, root doltdb.RootValue, inferredCols *schema.ColCollection, impOpts *importOptions) (schema.Schema, errhand.VerboseError) {
existingCols := impOpts.existingSch.GetAllCols()
// oldCols is the subset of existingCols that will be kept in the new schema
var oldCols *schema.ColCollection
// newCols is the subset of inferredCols that will be added to the new schema
var newCols *schema.ColCollection
var verr errhand.VerboseError
switch impOpts.op {
case CreateOp:
oldCols = schema.EmptyColColl
newCols = columnsForSchemaCreate(inferredCols, impOpts.PkCols)
case UpdateOp:
oldCols, newCols, verr = columnsForSchemaUpdate(existingCols, inferredCols, impOpts.keepTypes)
case ReplaceOp:
oldCols, newCols, verr = columnsForSchemaReplace(existingCols, inferredCols, impOpts.keepTypes)
}
if verr != nil {
return nil, verr
}
// NOTE: This code is only used in the import codepath for Dolt, so we don't use a schema to qualify the table name
newCols, err := doltdb.GenerateTagsForNewColColl(ctx, root, impOpts.tableName, newCols)
if err != nil {
return nil, errhand.BuildDError("failed to generate new schema").AddCause(err).Build()
}
combined := oldCols.AppendColl(newCols)
err = schema.ValidateForInsert(combined)
if err != nil {
return nil, errhand.BuildDError("invalid schema").AddCause(err).Build()
}
sch, err := schema.SchemaFromCols(combined)
if err != nil {
return nil, errhand.BuildDError("failed to get schema from cols").AddCause(err).Build()
}
return sch, nil
}
func columnsForSchemaCreate(inferredCols *schema.ColCollection, pkNames []string) (newCols *schema.ColCollection) {
pks := set.NewStrSet(pkNames)
newCols = schema.MapColCollection(inferredCols, func(col schema.Column) schema.Column {
col.IsPartOfPK = pks.Contains(col.Name)
return col
})
return newCols
}
func columnsForSchemaUpdate(existingCols, inferredCols *schema.ColCollection, keepTypes bool) (oldCols, newCols *schema.ColCollection, verr errhand.VerboseError) {
ecn := set.NewStrSet(existingCols.GetColumnNames())
icn := set.NewStrSet(inferredCols.GetColumnNames())
// (L - R), (L ∩ R), (R - L)
left, inter, right := ecn.LeftIntersectionRight(icn)
// intersection columns with the same types are added to oldCols
sameType := set.NewStrSet(nil)
inter.Iterate(func(colName string) (cont bool) {
ec, _ := existingCols.GetByName(colName)
ic, _ := inferredCols.GetByName(colName)
if ec.TypeInfo.Equals(ic.TypeInfo) {
sameType.Add(colName)
}
return true
})
if keepTypes {
oldCols = existingCols
newCols = schema.FilterColCollection(inferredCols, func(col schema.Column) bool {
return right.Contains(col.Name)
})
} else {
oldCols = schema.FilterColCollection(existingCols, func(col schema.Column) bool {
return left.Contains(col.Name) || sameType.Contains(col.Name)
})
newCols = schema.FilterColCollection(inferredCols, func(col schema.Column) bool {
return !sameType.Contains(col.Name)
})
}
verr = verifyPKsUnchanged(existingCols, oldCols, newCols)
if verr != nil {
return nil, nil, verr
}
return oldCols, newCols, nil
}
func columnsForSchemaReplace(existingCols, inferredCols *schema.ColCollection, keepTypes bool) (oldCols, newCols *schema.ColCollection, verr errhand.VerboseError) {
ecn := set.NewStrSet(existingCols.GetColumnNames())
icn := set.NewStrSet(inferredCols.GetColumnNames())
// (L - R), (L ∩ R), (R - L)
_, inter, right := ecn.LeftIntersectionRight(icn)
// intersection columns with the same types are added to oldCols
sameType := set.NewStrSet(nil)
inter.Iterate(func(colName string) (cont bool) {
ec, _ := existingCols.GetByName(colName)
ic, _ := inferredCols.GetByName(colName)
if ec.TypeInfo.Equals(ic.TypeInfo) {
sameType.Add(colName)
}
return true
})
if keepTypes {
oldCols = schema.FilterColCollection(existingCols, func(col schema.Column) bool {
return inter.Contains(col.Name)
})
newCols = schema.FilterColCollection(inferredCols, func(col schema.Column) bool {
return right.Contains(col.Name)
})
} else {
oldCols = schema.FilterColCollection(existingCols, func(col schema.Column) bool {
return sameType.Contains(col.Name)
})
newCols = schema.FilterColCollection(inferredCols, func(col schema.Column) bool {
return !sameType.Contains(col.Name)
})
}
verr = verifyPKsUnchanged(existingCols, oldCols, newCols)
if verr != nil {
return nil, nil, verr
}
return oldCols, newCols, nil
}
func verifyPKsUnchanged(existingCols, oldCols, newCols *schema.ColCollection) errhand.VerboseError {
err := newCols.Iter(func(tag uint64, col schema.Column) (stop bool, err error) {
if col.IsPartOfPK {
return true, fmt.Errorf("Cannot add primary keys using schema import")
}
return false, nil
})
if err != nil {
return errhand.VerboseErrorFromError(err)
}
existingPKs := schema.FilterColCollection(existingCols, func(col schema.Column) bool {
return col.IsPartOfPK
})
newPKs := schema.FilterColCollection(oldCols, func(col schema.Column) bool {
return col.IsPartOfPK
})
if !schema.ColCollsAreEqual(existingPKs, newPKs) {
return errhand.BuildDError("input primary keys do not match primary keys of existing table").Build()
}
return nil
}