github.com/mithrandie/csvq@v1.18.1/lib/cli/app.go (about)

     1  package cli
     2  
     3  import (
     4  	"context"
     5  	"log"
     6  	"os"
     7  	"os/signal"
     8  	"sort"
     9  
    10  	"github.com/mithrandie/csvq/lib/action"
    11  	"github.com/mithrandie/csvq/lib/file"
    12  	"github.com/mithrandie/csvq/lib/option"
    13  	"github.com/mithrandie/csvq/lib/parser"
    14  	"github.com/mithrandie/csvq/lib/query"
    15  
    16  	"github.com/urfave/cli/v2"
    17  )
    18  
    19  func Run() {
    20  	action.CurrentVersion, _ = action.ParseVersion(query.Version)
    21  	if !action.CurrentVersion.IsEmpty() {
    22  		query.Version = action.CurrentVersion.String()
    23  	}
    24  
    25  	cli.AppHelpTemplate = appHHelpTemplate
    26  	cli.CommandHelpTemplate = commandHelpTemplate
    27  
    28  	app := cli.NewApp()
    29  
    30  	app.Name = "csvq"
    31  	app.Usage = "SQL-like query language for csv"
    32  	app.ArgsUsage = "[query|argument]"
    33  	app.Version = query.Version
    34  	app.Authors = []*cli.Author{{Name: "Yuki et al."}}
    35  	app.OnUsageError = onUsageError
    36  	app.Flags = []cli.Flag{
    37  		&cli.StringFlag{
    38  			Name:    "repository",
    39  			Aliases: []string{"r"},
    40  			Usage:   "directory `PATH` where files are located",
    41  		},
    42  		&cli.StringFlag{
    43  			Name:    "timezone",
    44  			Aliases: []string{"z"},
    45  			Value:   "Local",
    46  			Usage:   "default timezone",
    47  		},
    48  		&cli.StringFlag{
    49  			Name:    "datetime-format",
    50  			Aliases: []string{"t"},
    51  			Usage:   "datetime format to parse strings",
    52  		},
    53  		&cli.BoolFlag{
    54  			Name:    "ansi-quotes",
    55  			Aliases: []string{"k"},
    56  			Usage:   "use double quotation mark as identifier enclosure",
    57  		},
    58  		&cli.BoolFlag{
    59  			Name:    "strict-equal",
    60  			Aliases: []string{"g"},
    61  			Usage:   "compare strictly equal or not in DISTINCT, GROUP BY and ORDER BY",
    62  		},
    63  		&cli.Float64Flag{
    64  			Name:    "wait-timeout",
    65  			Aliases: []string{"w"},
    66  			Value:   10,
    67  			Usage:   "maximum time in seconds to wait for locked files to be released",
    68  		},
    69  		&cli.StringFlag{
    70  			Name:    "source",
    71  			Aliases: []string{"s"},
    72  			Usage:   "load query or statements from `FILE`",
    73  		},
    74  		&cli.StringFlag{
    75  			Name:    "import-format",
    76  			Aliases: []string{"i"},
    77  			Value:   "CSV",
    78  			Usage:   "default format to load files",
    79  		},
    80  		&cli.StringFlag{
    81  			Name:    "delimiter",
    82  			Aliases: []string{"d"},
    83  			Value:   ",",
    84  			Usage:   "field delimiter for CSV",
    85  		},
    86  		&cli.BoolFlag{
    87  			Name:  "allow-uneven-fields",
    88  			Usage: "allow loading CSV files with uneven field length",
    89  		},
    90  		&cli.StringFlag{
    91  			Name:    "delimiter-positions",
    92  			Aliases: []string{"m"},
    93  			Usage:   "delimiter positions for FIXED",
    94  		},
    95  		&cli.StringFlag{
    96  			Name:    "json-query",
    97  			Aliases: []string{"j"},
    98  			Usage:   "`QUERY` for JSON",
    99  		},
   100  		&cli.StringFlag{
   101  			Name:    "encoding",
   102  			Aliases: []string{"e"},
   103  			Value:   "AUTO",
   104  			Usage:   "file encoding",
   105  		},
   106  		&cli.BoolFlag{
   107  			Name:    "no-header",
   108  			Aliases: []string{"n"},
   109  			Usage:   "import the first line as a record",
   110  		},
   111  		&cli.BoolFlag{
   112  			Name:    "without-null",
   113  			Aliases: []string{"a"},
   114  			Usage:   "parse empty fields as empty strings",
   115  		},
   116  		&cli.StringFlag{
   117  			Name:    "out",
   118  			Aliases: []string{"o"},
   119  			Usage:   "export result sets of select queries to `FILE`",
   120  		},
   121  		&cli.BoolFlag{
   122  			Name:    "strip-ending-line-break",
   123  			Aliases: []string{"T"},
   124  			Usage:   "strip line break from the end of files and query results",
   125  		},
   126  		&cli.StringFlag{
   127  			Name:    "format",
   128  			Aliases: []string{"f"},
   129  			Usage:   "format of query results. (default: \"CSV\" for output to pipe, \"TEXT\" otherwise)",
   130  		},
   131  		&cli.StringFlag{
   132  			Name:    "write-encoding",
   133  			Aliases: []string{"E"},
   134  			Value:   "UTF8",
   135  			Usage:   "character encoding of query results",
   136  		},
   137  		&cli.StringFlag{
   138  			Name:    "write-delimiter",
   139  			Aliases: []string{"D"},
   140  			Value:   ",",
   141  			Usage:   "field delimiter for CSV in query results",
   142  		},
   143  		&cli.StringFlag{
   144  			Name:    "write-delimiter-positions",
   145  			Aliases: []string{"M"},
   146  			Usage:   "delimiter positions for FIXED in query results",
   147  		},
   148  		&cli.BoolFlag{
   149  			Name:    "without-header",
   150  			Aliases: []string{"N"},
   151  			Usage:   "export result sets of select queries without the header line",
   152  		},
   153  		&cli.StringFlag{
   154  			Name:    "line-break",
   155  			Aliases: []string{"l"},
   156  			Value:   "LF",
   157  			Usage:   "line break in query results",
   158  		},
   159  		&cli.BoolFlag{
   160  			Name:    "enclose-all",
   161  			Aliases: []string{"Q"},
   162  			Usage:   "enclose all string values in CSV and TSV",
   163  		},
   164  		&cli.StringFlag{
   165  			Name:    "json-escape",
   166  			Aliases: []string{"J"},
   167  			Value:   "BACKSLASH",
   168  			Usage:   "JSON escape type",
   169  		},
   170  		&cli.BoolFlag{
   171  			Name:    "pretty-print",
   172  			Aliases: []string{"P"},
   173  			Usage:   "make JSON output easier to read in query results",
   174  		},
   175  		&cli.BoolFlag{
   176  			Name:    "scientific-notation",
   177  			Aliases: []string{"SN"},
   178  			Usage:   "use scientific notation for large exponents in output",
   179  		},
   180  		&cli.BoolFlag{
   181  			Name:    "east-asian-encoding",
   182  			Aliases: []string{"W"},
   183  			Usage:   "count ambiguous characters as fullwidth",
   184  		},
   185  		&cli.BoolFlag{
   186  			Name:    "count-diacritical-sign",
   187  			Aliases: []string{"S"},
   188  			Usage:   "count diacritical signs as halfwidth",
   189  		},
   190  		&cli.BoolFlag{
   191  			Name:    "count-format-code",
   192  			Aliases: []string{"A"},
   193  			Usage:   "count format characters and zero-width spaces as halfwidth",
   194  		},
   195  		&cli.BoolFlag{
   196  			Name:    "color",
   197  			Aliases: []string{"c"},
   198  			Usage:   "use ANSI color escape sequences",
   199  		},
   200  		&cli.BoolFlag{
   201  			Name:    "quiet",
   202  			Aliases: []string{"q"},
   203  			Usage:   "suppress operation log output",
   204  		},
   205  		&cli.IntFlag{
   206  			Name:  "limit-recursion",
   207  			Value: 1000,
   208  			Usage: "maximum number of iterations for recursive queries",
   209  		},
   210  		&cli.IntFlag{
   211  			Name:    "cpu",
   212  			Aliases: []string{"p"},
   213  			Value:   option.GetDefaultNumberOfCPU(),
   214  			Usage:   "hint for the number of cpu cores to be used",
   215  		},
   216  		&cli.BoolFlag{
   217  			Name:    "stats",
   218  			Aliases: []string{"x"},
   219  			Usage:   "show execution time and memory statistics",
   220  		},
   221  	}
   222  
   223  	app.Commands = []*cli.Command{
   224  		{
   225  			Name:      "fields",
   226  			Usage:     "Show fields in a file",
   227  			ArgsUsage: "DATA_FILE_PATH",
   228  			Action: commandAction(func(ctx context.Context, c *cli.Context, proc *query.Processor) error {
   229  				if 1 != c.NArg() {
   230  					return query.NewIncorrectCommandUsageError("fields subcommand takes exactly 1 argument")
   231  				}
   232  				table := c.Args().First()
   233  				return action.ShowFields(ctx, proc, table)
   234  			}),
   235  		},
   236  		{
   237  			Name:      "calc",
   238  			Usage:     "Calculate a value from stdin",
   239  			ArgsUsage: "expression",
   240  			Action: commandAction(func(ctx context.Context, c *cli.Context, proc *query.Processor) error {
   241  				if !proc.Tx.Session.CanReadStdin {
   242  					return query.NewIncorrectCommandUsageError(query.ErrMsgStdinEmpty)
   243  				}
   244  				if 1 != c.NArg() {
   245  					return query.NewIncorrectCommandUsageError("calc subcommand takes exactly 1 argument")
   246  				}
   247  				expr := c.Args().First()
   248  				return action.Calc(ctx, proc, expr)
   249  			}),
   250  		},
   251  		{
   252  			Name:      "syntax",
   253  			Usage:     "Print syntax",
   254  			ArgsUsage: "[search_word ...]",
   255  			Action: commandAction(func(ctx context.Context, c *cli.Context, proc *query.Processor) error {
   256  				words := append([]string{c.Args().First()}, c.Args().Tail()...)
   257  				return action.Syntax(ctx, proc, words)
   258  			}),
   259  		},
   260  		{
   261  			Name:      "check-update",
   262  			Usage:     "Check for updates",
   263  			ArgsUsage: " ",
   264  			Flags: []cli.Flag{
   265  				&cli.BoolFlag{
   266  					Name:  "include-pre-release",
   267  					Usage: "check including pre-release version",
   268  				},
   269  			},
   270  			Action: commandAction(func(ctx context.Context, c *cli.Context, proc *query.Processor) error {
   271  				if 0 < c.NArg() {
   272  					return query.NewIncorrectCommandUsageError("check-update subcommand takes no argument")
   273  				}
   274  
   275  				includePreRelease := false
   276  				if c.IsSet("include-pre-release") {
   277  					includePreRelease = c.Bool("include-pre-release")
   278  				}
   279  				return action.CheckUpdate(includePreRelease)
   280  			}),
   281  		},
   282  	}
   283  
   284  	for i := range app.Commands {
   285  		app.Commands[i].OnUsageError = onUsageError
   286  	}
   287  
   288  	app.Action = commandAction(func(ctx context.Context, c *cli.Context, proc *query.Processor) error {
   289  		queryString, path, err := readQuery(ctx, c, proc.Tx)
   290  		if err != nil {
   291  			return err
   292  		}
   293  
   294  		if len(queryString) < 1 {
   295  			err = action.LaunchInteractiveShell(ctx, proc)
   296  		} else {
   297  			err = action.Run(ctx, proc, queryString, path, c.String("out"))
   298  		}
   299  
   300  		return err
   301  	})
   302  
   303  	sort.Sort(cli.FlagsByName(app.Flags))
   304  	sort.Sort(cli.CommandsByName(app.Commands))
   305  
   306  	if err := app.Run(os.Args); err != nil {
   307  		log.Fatalln(err.Error())
   308  	}
   309  }
   310  
   311  func onUsageError(c *cli.Context, err error, isSubcommand bool) error {
   312  	if isSubcommand {
   313  		if e := cli.ShowCommandHelp(c, c.Command.Name); e != nil {
   314  			println(e.Error())
   315  		}
   316  	}
   317  	if _, ok := err.(*query.IncorrectCommandUsageError); !ok {
   318  		err = query.NewIncorrectCommandUsageError(err.Error())
   319  	}
   320  	return Exit(err, nil)
   321  }
   322  
   323  func commandAction(fn func(ctx context.Context, c *cli.Context, proc *query.Processor) error) func(c *cli.Context) error {
   324  	return func(c *cli.Context) (err error) {
   325  		ctx, cancel := context.WithCancel(context.Background())
   326  		defer cancel()
   327  
   328  		session := query.NewSession()
   329  		tx, e := query.NewTransaction(ctx, file.DefaultWaitTimeout, file.DefaultRetryDelay, session)
   330  		if e != nil {
   331  			return Exit(e, nil)
   332  		}
   333  
   334  		proc := query.NewProcessor(tx)
   335  		defer func() {
   336  			if e := proc.AutoRollback(); e != nil {
   337  				proc.LogError(e.Error())
   338  			}
   339  			if e := proc.ReleaseResourcesWithErrors(); e != nil {
   340  				proc.LogError(e.Error())
   341  			}
   342  
   343  			if err != nil {
   344  				if _, ok := err.(*query.IncorrectCommandUsageError); ok {
   345  					err = onUsageError(c, err, 0 < len(c.Command.Name))
   346  				} else {
   347  					err = Exit(err, proc.Tx)
   348  				}
   349  			}
   350  		}()
   351  
   352  		// Handle signals
   353  		ch := make(chan os.Signal, 1)
   354  		signal.Notify(ch, action.Signals...)
   355  		var signalReceived error
   356  
   357  		go func() {
   358  			sig := <-ch
   359  			signalReceived = query.NewSignalReceived(sig)
   360  			cancel()
   361  		}()
   362  
   363  		// Run preload commands
   364  		if err = runPreloadCommands(ctx, proc); err != nil {
   365  			return
   366  		}
   367  
   368  		// Overwrite Flags with Command Options
   369  		if err = overwriteFlags(c, proc.Tx); err != nil {
   370  			return
   371  		}
   372  
   373  		err = fn(ctx, c, proc)
   374  		if signalReceived != nil {
   375  			err = signalReceived
   376  		}
   377  		return
   378  	}
   379  }
   380  
   381  func overwriteFlags(c *cli.Context, tx *query.Transaction) error {
   382  	if c.IsSet("repository") {
   383  		if err := tx.SetFlag(option.RepositoryFlag, c.String("repository")); err != nil {
   384  			return query.NewIncorrectCommandUsageError(err.Error())
   385  		}
   386  	}
   387  	if c.IsSet("timezone") {
   388  		if err := tx.SetFlag(option.TimezoneFlag, c.String("timezone")); err != nil {
   389  			return query.NewIncorrectCommandUsageError(err.Error())
   390  		}
   391  	}
   392  	if c.IsSet("datetime-format") {
   393  		_ = tx.SetFlag(option.DatetimeFormatFlag, c.String("datetime-format"))
   394  	}
   395  	if c.IsSet("ansi-quotes") {
   396  		_ = tx.SetFlag(option.AnsiQuotesFlag, c.Bool("ansi-quotes"))
   397  	}
   398  	if c.IsSet("strict-equal") {
   399  		_ = tx.SetFlag(option.StrictEqualFlag, c.Bool("strict-equal"))
   400  	}
   401  
   402  	if c.IsSet("wait-timeout") {
   403  		_ = tx.SetFlag(option.WaitTimeoutFlag, c.Float64("wait-timeout"))
   404  	}
   405  	if c.IsSet("color") {
   406  		_ = tx.SetFlag(option.ColorFlag, c.Bool("color"))
   407  	}
   408  
   409  	if c.IsSet("import-format") {
   410  		if err := tx.SetFlag(option.ImportFormatFlag, c.String("import-format")); err != nil {
   411  			return query.NewIncorrectCommandUsageError(err.Error())
   412  		}
   413  	}
   414  	if c.IsSet("delimiter") {
   415  		if err := tx.SetFlag(option.DelimiterFlag, c.String("delimiter")); err != nil {
   416  			return query.NewIncorrectCommandUsageError(err.Error())
   417  		}
   418  	}
   419  	if c.IsSet("allow-uneven-fields") {
   420  		_ = tx.SetFlag(option.AllowUnevenFieldsFlag, c.Bool("allow-uneven-fields"))
   421  	}
   422  	if c.IsSet("delimiter-positions") {
   423  		if err := tx.SetFlag(option.DelimiterPositionsFlag, c.String("delimiter-positions")); err != nil {
   424  			return query.NewIncorrectCommandUsageError(err.Error())
   425  		}
   426  	}
   427  	if c.IsSet("json-query") {
   428  		_ = tx.SetFlag(option.JsonQueryFlag, c.String("json-query"))
   429  	}
   430  	if c.IsSet("encoding") {
   431  		if err := tx.SetFlag(option.EncodingFlag, c.String("encoding")); err != nil {
   432  			return query.NewIncorrectCommandUsageError(err.Error())
   433  		}
   434  	}
   435  	if c.IsSet("no-header") {
   436  		_ = tx.SetFlag(option.NoHeaderFlag, c.Bool("no-header"))
   437  	}
   438  	if c.IsSet("without-null") {
   439  		_ = tx.SetFlag(option.WithoutNullFlag, c.Bool("without-null"))
   440  	}
   441  
   442  	if c.IsSet("strip-ending-line-break") {
   443  		_ = tx.SetFlag(option.StripEndingLineBreakFlag, c.Bool("strip-ending-line-break"))
   444  	}
   445  
   446  	if err := tx.SetFormatFlag(c.String("format"), c.String("out")); err != nil {
   447  		return query.NewIncorrectCommandUsageError(err.Error())
   448  	}
   449  
   450  	if c.IsSet("write-encoding") {
   451  		if err := tx.SetFlag(option.ExportEncodingFlag, c.String("write-encoding")); err != nil {
   452  			return query.NewIncorrectCommandUsageError(err.Error())
   453  		}
   454  	}
   455  	if c.IsSet("write-delimiter") {
   456  		if err := tx.SetFlag(option.ExportDelimiterFlag, c.String("write-delimiter")); err != nil {
   457  			return query.NewIncorrectCommandUsageError(err.Error())
   458  		}
   459  	}
   460  	if c.IsSet("write-delimiter-positions") {
   461  		if err := tx.SetFlag(option.ExportDelimiterPositionsFlag, c.String("write-delimiter-positions")); err != nil {
   462  			return query.NewIncorrectCommandUsageError(err.Error())
   463  		}
   464  	}
   465  	if c.IsSet("without-header") {
   466  		_ = tx.SetFlag(option.WithoutHeaderFlag, c.Bool("without-header"))
   467  	}
   468  	if c.IsSet("line-break") {
   469  		if err := tx.SetFlag(option.LineBreakFlag, c.String("line-break")); err != nil {
   470  			return query.NewIncorrectCommandUsageError(err.Error())
   471  		}
   472  	}
   473  	if c.IsSet("enclose-all") {
   474  		_ = tx.SetFlag(option.EncloseAllFlag, c.Bool("enclose-all"))
   475  	}
   476  	if c.IsSet("json-escape") {
   477  		if err := tx.SetFlag(option.JsonEscapeFlag, c.String("json-escape")); err != nil {
   478  			return query.NewIncorrectCommandUsageError(err.Error())
   479  		}
   480  	}
   481  	if c.IsSet("pretty-print") {
   482  		_ = tx.SetFlag(option.PrettyPrintFlag, c.Bool("pretty-print"))
   483  	}
   484  	if c.IsSet("scientific-notation") {
   485  		_ = tx.SetFlag(option.ScientificNotationFlag, c.Bool("scientific-notation"))
   486  	}
   487  
   488  	if c.IsSet("east-asian-encoding") {
   489  		_ = tx.SetFlag(option.EastAsianEncodingFlag, c.Bool("east-asian-encoding"))
   490  	}
   491  	if c.IsSet("count-diacritical-sign") {
   492  		_ = tx.SetFlag(option.CountDiacriticalSignFlag, c.Bool("count-diacritical-sign"))
   493  	}
   494  	if c.IsSet("count-format-code") {
   495  		_ = tx.SetFlag(option.CountFormatCodeFlag, c.Bool("count-format-code"))
   496  	}
   497  
   498  	if c.IsSet("quiet") {
   499  		_ = tx.SetFlag(option.QuietFlag, c.Bool("quiet"))
   500  	}
   501  	if c.IsSet("limit-recursion") {
   502  		_ = tx.SetFlag(option.LimitRecursion, c.Int64("limit-recursion"))
   503  	}
   504  	if c.IsSet("cpu") {
   505  		_ = tx.SetFlag(option.CPUFlag, c.Int64("cpu"))
   506  	}
   507  	if c.IsSet("stats") {
   508  		_ = tx.SetFlag(option.StatsFlag, c.Bool("stats"))
   509  	}
   510  
   511  	return nil
   512  }
   513  
   514  func runPreloadCommands(ctx context.Context, proc *query.Processor) (err error) {
   515  	files := option.GetSpecialFilePath(option.PreloadCommandFileName)
   516  	for _, fpath := range files {
   517  		if !file.Exists(fpath) {
   518  			continue
   519  		}
   520  
   521  		statements, err := query.LoadStatementsFromFile(ctx, proc.Tx, parser.Identifier{Literal: fpath})
   522  		if err != nil {
   523  			return err
   524  		}
   525  
   526  		if _, err := proc.Execute(ctx, statements); err != nil {
   527  			return err
   528  		}
   529  	}
   530  	return nil
   531  }
   532  
   533  func readQuery(ctx context.Context, c *cli.Context, tx *query.Transaction) (queryString string, path string, err error) {
   534  	if c.IsSet("source") && 0 < len(c.String("source")) {
   535  		if 0 < c.NArg() {
   536  			err = query.NewIncorrectCommandUsageError("no argument can be passed when \"--source\" option is specified")
   537  		} else {
   538  			path = c.String("source")
   539  			queryString, err = query.LoadContentsFromFile(ctx, tx, parser.Identifier{Literal: path})
   540  		}
   541  	} else {
   542  		switch c.NArg() {
   543  		case 0:
   544  			// Launch interactive shell
   545  		case 1:
   546  			queryString = c.Args().First()
   547  		default:
   548  			err = query.NewIncorrectCommandUsageError("csvq command takes exactly 1 argument")
   549  		}
   550  	}
   551  	return
   552  }
   553  
   554  func Exit(err error, tx *query.Transaction) error {
   555  	if err == nil {
   556  		return nil
   557  	}
   558  	if exit, ok := err.(*query.ForcedExit); ok && exit.Code() == 0 {
   559  		return nil
   560  	}
   561  
   562  	code := query.ReturnCodeApplicationError
   563  	message := err.Error()
   564  	if tx != nil {
   565  		message = tx.Error(message)
   566  	}
   567  
   568  	if apperr, ok := err.(query.Error); ok {
   569  		code = apperr.Code()
   570  	}
   571  
   572  	return cli.Exit(message, code)
   573  }