├── .github └── workflows │ └── go.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── VERSION ├── cmd └── fsql │ └── main.go ├── evaluate ├── compare.go ├── compare_test.go ├── error.go ├── error_test.go ├── evaluate.go └── evaluate_test.go ├── fsql.go ├── fsql_test.go ├── go.mod ├── go.sum ├── media └── fsql.gif ├── meta └── meta.go ├── parser ├── attribute.go ├── attribute_test.go ├── condition.go ├── condition_test.go ├── error.go ├── error_test.go ├── parser.go ├── parser_test.go ├── source.go └── source_test.go ├── query ├── condition.go ├── condition_test.go ├── excluder.go ├── excluder_test.go ├── modifier.go ├── modifier_test.go ├── query.go └── query_test.go ├── terminal ├── pager │ └── pager.go ├── terminal.go └── terminal_test.go ├── testdata ├── bar │ ├── corge │ ├── garply │ │ └── xyzzy │ │ │ └── thud │ │ │ └── .gitkeep │ └── grault ├── baz └── foo │ ├── quux │ ├── quuz │ ├── fred │ │ └── .gitkeep │ └── waldo │ └── qux ├── tokenizer ├── token.go ├── token_test.go ├── tokenizer.go └── tokenizer_test.go └── transform ├── common.go ├── common_test.go ├── error.go ├── error_test.go ├── format.go ├── format_test.go ├── parse.go └── parse_test.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | go-version: [ '1.19', '1.20', '1.21' ] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go ${{ matrix.go-version }} 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: ${{ matrix.go-version }} 23 | 24 | - name: Install dependencies 25 | run: go get . 26 | 27 | - name: Build 28 | run: make 29 | 30 | - name: Test 31 | run: make test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | # Output of the go coverage tool, specifically when used with LiteIDE 27 | *.out 28 | 29 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 30 | .glide/ 31 | 32 | # Compiled binaries 33 | fsql 34 | debug 35 | main 36 | dist/ 37 | 38 | # Allow cmd/fsql 39 | !cmd/fsql 40 | 41 | # Editor workspace directories 42 | .vscode 43 | 44 | # Coverage output 45 | coverage.txt 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Kashav Madan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PREFIX ?= $(shell pwd) 2 | 3 | NAME = fsql 4 | PKG = github.com/kashav/$(NAME) 5 | MAIN = $(PKG)/cmd/$(NAME) 6 | 7 | DIST_DIR := ${PREFIX}/dist 8 | DIST_DIRS := find . -type d | sed "s|^\./||" | grep -v \\. | tr '\n' '\0' | xargs -0 -I '{}' 9 | 10 | SRCS := $(shell find . -type f -name '*.go') 11 | PKGS := $(shell go list ./...) 12 | 13 | VERSION := $(shell cat VERSION) 14 | GITCOMMIT := $(shell git rev-parse --short HEAD) 15 | ifneq ($(shell git status --porcelain --untracked-files=no),) 16 | GITCOMMIT := $(GITCOMMIT)-dirty 17 | endif 18 | 19 | LDFLAGS := ${LDFLAGS} \ 20 | -X $(PKG)/meta.GITCOMMIT=${GITCOMMIT} \ 21 | -X $(PKG)/meta.VERSION=${VERSION} 22 | 23 | .PHONY: all 24 | all: $(NAME) 25 | 26 | $(NAME): $(SRCS) VERSION 27 | @echo "+ $@" 28 | @go build -ldflags "${LDFLAGS}" -o $(NAME) -v $(MAIN) 29 | 30 | .PHONY: install 31 | install: 32 | @echo "+ $@" 33 | @go install $(PKGS) 34 | 35 | .PHONY: get-tools 36 | get-tools: 37 | @echo "+ $@" 38 | @go get -u -v golang.org/x/lint/golint 39 | 40 | .PHONY: clean 41 | clean: 42 | @echo "+ $@" 43 | $(RM) $(NAME) 44 | $(RM) -r $(DIST_DIR) 45 | 46 | .PHONY: fmt 47 | fmt: 48 | @echo "+ $@" 49 | @test -z "$$(gofmt -s -l . 2>&1 | tee /dev/stderr)" || \ 50 | (echo >&2 "+ please format Go code with 'gofmt -s', or use 'make fmt-save'" && false) 51 | 52 | .PHONY: fmt-save 53 | fmt-save: 54 | @echo "+ $@" 55 | @gofmt -s -l . 2>&1 | xargs gofmt -s -l -w 56 | 57 | .PHONY: vet 58 | vet: 59 | @echo "+ $@" 60 | @go vet $(PKGS) 61 | 62 | .PHONY: lint 63 | lint: 64 | @echo "+ $@" 65 | $(if $(shell which golint || echo ''),, \ 66 | $(error Please install golint: `make get-tools`)) 67 | @test -z "$$(golint ./... 2>&1 | grep -v mock/ | tee /dev/stderr)" 68 | 69 | .PHONY: test 70 | test: 71 | @echo "+ $@" 72 | @go test -race $(PKGS) 73 | 74 | .PHONY: coverage 75 | coverage: 76 | @echo "+ $@" 77 | @for pkg in $(PKGS); do \ 78 | go test -test.short -race -coverprofile="../../../$$pkg/coverage.txt" $${pkg} || exit 1; \ 79 | done 80 | 81 | .PHONY: bootstrap-dist 82 | bootstrap-dist: 83 | @echo "+ $@" 84 | @go get -u -v github.com/franciscocpg/gox 85 | 86 | .PHONY: build-all 87 | build-all: $(SRCS) VERSION 88 | @echo "+ $@" 89 | @gox -verbose \ 90 | -ldflags "${LDFLAGS}" \ 91 | -os="darwin freebsd netbsd openbsd linux solaris windows" \ 92 | -arch="386 amd64 arm arm64" \ 93 | -osarch="!darwin/386 !darwin/arm" \ 94 | -output="$(DIST_DIR)/$(NAME)-{{.OS}}-{{.Arch}}/{{.Dir}}" $(MAIN) 95 | 96 | .PHONY: dist 97 | dist: clean build-all 98 | @echo "+ $@" 99 | @cd $(DIST_DIR) && \ 100 | $(DIST_DIRS) cp ../LICENSE {} && \ 101 | $(DIST_DIRS) cp ../README.md {} && \ 102 | $(DIST_DIRS) tar -zcf fsql-${VERSION}-{}.tar.gz {} && \ 103 | $(DIST_DIRS) zip -r -q fsql-${VERSION}-{}.zip {} && \ 104 | $(DIST_DIRS) rm -rf {} && \ 105 | cd .. 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fsql [![Go](https://github.com/kashav/fsql/actions/workflows/go.yml/badge.svg)](https://github.com/kashav/fsql/actions/workflows/go.yml) 2 | 3 | >Search through your filesystem with SQL-esque queries. 4 | 5 | ## Contents 6 | 7 | - [Demo](#demo) 8 | - [Installation](#installation) 9 | - [Usage](#usage) 10 | - [Query Syntax](#query-syntax) 11 | - [Examples](#usage-examples) 12 | - [Contribute](#contribute) 13 | - [License](#license) 14 | 15 | ## Demo 16 | 17 | [![fsql.gif](./media/fsql.gif)](https://asciinema.org/a/120534) 18 | 19 | ## Installation 20 | 21 | #### Binaries 22 | 23 | [View latest release](https://github.com/kashav/fsql/releases/latest). 24 | 25 | #### Via Go 26 | 27 | ```sh 28 | $ go get -u -v github.com/kashav/fsql/... 29 | $ which fsql 30 | $GOPATH/bin/fsql 31 | ``` 32 | 33 | #### Via Homebrew 34 | 35 | ```sh 36 | $ brew install fsql 37 | $ which fsql 38 | /usr/local/bin/fsql 39 | ``` 40 | 41 | #### Build manually 42 | 43 | ```sh 44 | $ git clone https://github.com/kashav/fsql.git $GOPATH/src/github.com/kashav/fsql 45 | $ cd $_ # $GOPATH/src/github.com/kashav/fsql 46 | $ make 47 | $ ./fsql 48 | ``` 49 | 50 | ## Usage 51 | 52 | fsql expects a single query via stdin. You may also choose to use fsql in interactive mode. 53 | 54 | View the usage dialogue with the `-help` flag. 55 | 56 | ```sh 57 | $ fsql -help 58 | usage: fsql [options] [query] 59 | -v print version and exit (shorthand) 60 | -version 61 | print version and exit 62 | ``` 63 | 64 | ## Query syntax 65 | 66 | In general, each query requires a `SELECT` clause (to specify which attributes will be shown), a `FROM` clause (to specify which directories to search), and a `WHERE` clause (to specify conditions to test against). 67 | 68 | ```console 69 | >>> SELECT attribute, ... FROM source, ... WHERE condition; 70 | ``` 71 | 72 | You may choose to omit the `SELECT` and `WHERE` clause. 73 | 74 | If you're providing your query via stdin, quotes are **not** required, however you'll have to escape _reserved_ characters (e.g. `*`, `<`, `>`, etc). 75 | 76 | ### Attribute 77 | 78 | Currently supported attributes include `name`, `size`, `time`, `hash`, `mode`. 79 | 80 | Use `all` or `*` to choose all; if no attribute is provided, this is chosen by default. 81 | 82 | **Examples**: 83 | 84 | Each group features a set of equivalent clauses. 85 | 86 | ```console 87 | >>> SELECT name, size, time ... 88 | >>> name, size, time ... 89 | ``` 90 | 91 | ```console 92 | >>> SELECT all FROM ... 93 | >>> all FROM ... 94 | >>> FROM ... 95 | ``` 96 | 97 | ### Source 98 | 99 | Each source should be a relative or absolute path to a directory on your machine. 100 | 101 | Source paths may include environment variables (e.g. `$GOPATH`) or tildes (`~`). Use a hyphen (`-`) to exclude a directory. Source paths also support usage of [glob patterns](https://en.wikipedia.org/wiki/Glob_(programming)). 102 | 103 | In the case that a directory begins with a hyphen (e.g. `-foo`), use the following to include it as a source: 104 | 105 | ```console 106 | >>> ... FROM ./-foo ... 107 | ``` 108 | 109 | **Examples**: 110 | 111 | ```console 112 | >>> ... FROM . ... 113 | ``` 114 | 115 | ```console 116 | >>> ... FROM ~/Desktop, ./*/**.go ... 117 | ``` 118 | 119 | ```console 120 | >>> ... FROM $GOPATH, -.git/ ... 121 | ``` 122 | 123 | ### Condition 124 | 125 | #### Condition syntax 126 | 127 | A single condition is made up of 3 parts: an attribute, an operator, and a value. 128 | 129 | - **Attribute**: 130 | 131 | A valid attribute is any of the following: `name`, `size`, `mode`, `time`. 132 | 133 | - **Operator**: 134 | 135 | Each attribute has a set of associated operators. 136 | 137 | - `name`: 138 | 139 | | Operator | Description | 140 | | :---: | --- | 141 | | `=` | String equality | 142 | | `<>` / `!=` | Synonymous to using `"NOT ... = ..."` | 143 | | `IN` | Basic list inclusion | 144 | | `LIKE` | Simple pattern matching. Use `%` to match zero, one, or multiple characters. Check that a string begins with a value: `%`, ends with a value: `%`, or contains a value: `%%`. | 145 | | `RLIKE` | Pattern matching with regular expressions. | 146 | 147 | - `size` / `time`: 148 | 149 | - All basic algebraic operators: `>`, `>=`, `<`, `<=`, `=`, and `<>` / `!=`. 150 | 151 | - `hash`: 152 | 153 | - `=` or `<>` / `!=` 154 | 155 | - `mode`: 156 | 157 | - `IS` 158 | 159 | 160 | - **Value**: 161 | 162 | If the value contains spaces, wrap the value in quotes (either single or double) or backticks. 163 | 164 | The default unit for `size` is bytes. 165 | 166 | The default format for `time` is `MMM DD YYYY HH MM` (e.g. `"Jan 02 2006 15 04"`). 167 | 168 | Use `mode` to test if a file is regular (`IS REG`) or if it's a directory (`IS DIR`). 169 | 170 | Use `hash` to compute and/or compare the hash value of a file. The default algorithm is `SHA1` 171 | 172 | #### Conjunction / Disjunction 173 | 174 | Use `AND` / `OR` to join conditions. Note that precedence is assigned based on order of appearance. 175 | 176 | This means `WHERE a AND b OR c` is **not** the same as `WHERE c OR b AND a`. Use parentheses to get around this behaviour, i.e. `WHERE a AND b OR c` **is** the same as `WHERE c OR (b AND a)`. 177 | 178 | **Examples**: 179 | 180 | ```console 181 | >>> ... WHERE name = main.go OR size = 5 ... 182 | ``` 183 | 184 | ```console 185 | >>> ... WHERE name = main.go AND size > 20 ... 186 | ``` 187 | 188 | #### Negation 189 | 190 | Use `NOT` to negate a condition. This keyword **must** precede the condition (e.g. `... WHERE NOT a ...`). 191 | 192 | Note that negating parenthesized conditions is currently not supported. However, this can easily be resolved by applying [De Morgan's laws](https://en.wikipedia.org/wiki/De_Morgan%27s_laws) to your query. For example, `... WHERE NOT (a AND b) ...` is _logically equivalent_ to `... WHERE NOT a OR NOT b ...` (the latter is actually more optimal, due to [lazy evaluation](https://en.wikipedia.org/wiki/Lazy_evaluation)). 193 | 194 | **Examples**: 195 | 196 | ```console 197 | >>> ... WHERE NOT name = main.go ... 198 | ``` 199 | 200 | ### Attribute Modifiers 201 | 202 | Attribute modifiers are used to specify how input and output values should be processed. These functions are applied directly to attributes in the `SELECT` and `WHERE` clauses. 203 | 204 | The table below lists currently-supported modifiers. Note that the first parameter to `FORMAT` is always the attribute name. 205 | 206 | | Attribute | Modifier | Supported in `SELECT` | Supported in `WHERE` | 207 | | :---: | --- | :---: | :---: | 208 | | `hash` | `SHA1(, n)` | ✔️ | ✔️ | 209 | | `name` | `UPPER` (synonymous to `FORMAT(, UPPER)`) | ✔️ | ✔️ | 210 | | | `LOWER` (synonymous to `FORMAT(, LOWER)`) | ✔️ | ✔️ | 211 | | | `FULLPATH` | ✔️ | | 212 | | | `SHORTPATH` | ✔️ | | 213 | | `size` | `FORMAT(, unit)` | ✔️ | ✔️ | 214 | | `time` | `FORMAT(, layout)` | ✔️ | ✔️ | 215 | 216 | 217 | - **`n`**: 218 | 219 | Specify the length of the hash value. Use a negative integer or `ALL` to display all digits. 220 | 221 | - **`unit`**: 222 | 223 | Specify the size unit. One of: `B` (byte), `KB` (kilobyte), `MB` (megabyte), or `GB` (gigabyte). 224 | 225 | - **`layout`**: 226 | 227 | Specify the time layout. One of: [`ISO`](https://en.wikipedia.org/wiki/ISO_8601), [`UNIX`](https://en.wikipedia.org/wiki/Unix_time), or [custom](https://golang.org/pkg/time/#Time.Format). Custom layouts must be provided in reference to the following date: `Mon Jan 2 15:04:05 -0700 MST 2006`. 228 | 229 | **Examples**: 230 | 231 | ```console 232 | >>> SELECT SHA1(hash, 20) ... 233 | ``` 234 | 235 | ```console 236 | >>> ... WHERE UPPER(name) ... 237 | ``` 238 | 239 | ```console 240 | >>> SELECT FORMAT(size, MB) ... 241 | ``` 242 | 243 | ```console 244 | >>> ... WHERE FORMAT(time, "Mon Jan 2 2006 15:04:05") ... 245 | ``` 246 | 247 | ### Subqueries 248 | 249 | Subqueries allow for more complex condition statements. These queries are recursively evaluated while parsing. SELECTing multiple attributes in a subquery is not currently supported; if more than one attribute (or `all`) is provided, only the first attribute is used. 250 | 251 | Support for referencing superqueries is not yet implemented, see [#4](https://github.com/kashav/fsql/issues/4) if you'd like to help with this. 252 | 253 | **Examples**: 254 | 255 | ```console 256 | >>> ... WHERE name IN (SELECT name FROM ../foo) ... 257 | ``` 258 | 259 | ## Usage Examples 260 | 261 | List all attributes of each directory in your home directory (note the escaped `*`): 262 | 263 | ```console 264 | $ fsql SELECT \* FROM ~ WHERE mode IS DIR 265 | ``` 266 | 267 | List the names of all files in the Desktop and Downloads directory that contain `csc` in the name: 268 | 269 | ```console 270 | $ fsql "SELECT name FROM ~/Desktop, ~/Downloads WHERE name LIKE %csc%" 271 | ``` 272 | 273 | List all files in the current directory that are also present in some other directory: 274 | 275 | ```console 276 | $ fsql 277 | >>> SELECT all FROM . WHERE name IN ( 278 | ... SELECT name FROM ~/Desktop/files.bak/ 279 | ... ); 280 | ``` 281 | 282 | Passing queries via stdin without quotes is a bit of a pain, hopefully the next examples highlight that, my suggestion is to use interactive mode or wrap the query in quotes if you're doing anything with subqueries or attribute modifiers. 283 | 284 | List all files named `main.go` in `$GOPATH` which are larger than 10.5 kilobytes or smaller than 100 bytes: 285 | 286 | ```console 287 | $ fsql SELECT all FROM $GOPATH WHERE name = main.go AND \(FORMAT\(size, KB\) \>= 10.5 OR size \< 100\) 288 | $ fsql "SELECT all FROM $GOPATH WHERE name = main.go AND (FORMAT(size, KB) >= 10.5 OR size < 100)" 289 | $ fsql 290 | >>> SELECT 291 | ... all 292 | ... FROM 293 | ... $GOPATH 294 | ... WHERE 295 | ... name = main.go 296 | ... AND ( 297 | ... FORMAT(size, KB) >= 10.5 298 | ... OR size < 100 299 | ... ) 300 | ... ; 301 | ``` 302 | 303 | List the name, size, and modification time of JavaScript files in the current directory that were modified after April 1st 2017: 304 | 305 | ```console 306 | $ fsql SELECT UPPER\(name\), FORMAT\(size, KB\), FORMAT\(time, ISO\) FROM . WHERE name LIKE %.js AND time \> \'Apr 01 2017 00 00\' 307 | $ fsql "SELECT UPPER(name), FORMAT(size, KB), FORMAT(time, ISO) FROM . WHERE name LIKE %.js AND time > 'Apr 01 2017 00 00'" 308 | $ fsql 309 | >>> SELECT 310 | ... UPPER(name), 311 | ... FORMAT(size, KB), 312 | ... FORMAT(time, ISO) 313 | ... FROM 314 | ... . 315 | ... WHERE 316 | ... name LIKE %.js 317 | ... AND time > 'Apr 01 2017 00 00' 318 | ... ; 319 | ``` 320 | 321 | ## Contribute 322 | 323 | This project is completely open source, feel free to [open an issue](https://github.com/kashav/fsql/issues) or [submit a pull request](https://github.com/kashav/fsql/pulls). 324 | 325 | Before submitting code, please ensure that tests are passing and the linter is happy. The following commands may be of use, refer to the [Makefile](./Makefile) to see what they do. 326 | 327 | ```sh 328 | $ make install \ 329 | get-tools 330 | $ make fmt \ 331 | vet \ 332 | lint 333 | $ make test \ 334 | coverage 335 | $ make bootstrap-dist \ 336 | dist 337 | ``` 338 | 339 | ## License 340 | 341 | fsql source code is available under the [MIT license](./LICENSE). 342 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.5.2 2 | -------------------------------------------------------------------------------- /cmd/fsql/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strings" 9 | 10 | "github.com/kashav/fsql" 11 | "github.com/kashav/fsql/meta" 12 | "github.com/kashav/fsql/terminal" 13 | ) 14 | 15 | var options struct { 16 | version bool 17 | } 18 | 19 | func readInput() string { 20 | if len(flag.Args()) > 1 { 21 | return strings.Join(flag.Args(), " ") 22 | } 23 | 24 | return flag.Args()[0] 25 | } 26 | 27 | func main() { 28 | flag.Usage = func() { 29 | fmt.Printf("usage: %s [options] [query]\n", os.Args[0]) 30 | flag.PrintDefaults() 31 | } 32 | 33 | flag.BoolVar(&options.version, "version", false, "print version and exit") 34 | flag.BoolVar(&options.version, "v", false, 35 | "print version and exit (shorthand)") 36 | flag.Parse() 37 | 38 | if options.version { 39 | fmt.Printf("%s\n", meta.Meta()) 40 | os.Exit(0) 41 | } 42 | 43 | if len(flag.Args()) == 0 { 44 | if err := terminal.Start(); err != nil { 45 | log.Fatal(err.Error()) 46 | } 47 | os.Exit(0) 48 | } 49 | 50 | if err := fsql.Run(readInput()); err != nil { 51 | log.Fatal(err.Error()) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /evaluate/compare.go: -------------------------------------------------------------------------------- 1 | package evaluate 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | "time" 8 | 9 | "github.com/kashav/fsql/tokenizer" 10 | "github.com/kashav/fsql/transform" 11 | ) 12 | 13 | // cmpAlpha performs alphabetic comparison on a and b. 14 | func cmpAlpha(o *Opts, a, b interface{}) (result bool, err error) { 15 | switch o.Operator { 16 | case tokenizer.Equals: 17 | result = a.(string) == b.(string) 18 | case tokenizer.NotEquals: 19 | result = a.(string) != b.(string) 20 | case tokenizer.Like: 21 | aStr, bStr := a.(string), b.(string) 22 | if strings.HasPrefix(bStr, "%") && strings.HasSuffix(bStr, "%") { 23 | result = strings.Contains(aStr, bStr[1:len(bStr)-1]) 24 | } else if strings.HasPrefix(bStr, "%") { 25 | result = strings.HasSuffix(aStr, bStr[1:]) 26 | } else if strings.HasSuffix(bStr, "%") { 27 | result = strings.HasPrefix(aStr, bStr[:len(bStr)-1]) 28 | } else { 29 | result = strings.Contains(aStr, bStr) 30 | } 31 | case tokenizer.RLike: 32 | result = regexp.MustCompile(b.(string)).MatchString(a.(string)) 33 | case tokenizer.In: 34 | switch t := b.(type) { 35 | case map[interface{}]bool: 36 | if _, ok := t[a.(string)]; ok { 37 | result = true 38 | } 39 | case []string: 40 | for _, el := range t { 41 | if a.(string) == el { 42 | result = true 43 | } 44 | } 45 | case string: 46 | for _, el := range strings.Split(t, ",") { 47 | if a.(string) == el { 48 | result = true 49 | } 50 | } 51 | } 52 | default: 53 | err = &ErrUnsupportedOperator{o.Attribute, o.Operator} 54 | } 55 | return result, err 56 | } 57 | 58 | // cmpNumeric performs numeric comparison on a and b. 59 | func cmpNumeric(o *Opts, a, b interface{}) (result bool, err error) { 60 | switch o.Operator { 61 | case tokenizer.Equals: 62 | result = a.(int64) == b.(int64) 63 | case tokenizer.NotEquals: 64 | result = a.(int64) != b.(int64) 65 | case tokenizer.GreaterThanEquals: 66 | result = a.(int64) >= b.(int64) 67 | case tokenizer.GreaterThan: 68 | result = a.(int64) > b.(int64) 69 | case tokenizer.LessThanEquals: 70 | result = a.(int64) <= b.(int64) 71 | case tokenizer.LessThan: 72 | result = a.(int64) < b.(int64) 73 | case tokenizer.In: 74 | if _, ok := b.(map[interface{}]bool)[a.(int64)]; ok { 75 | result = true 76 | } 77 | default: 78 | err = &ErrUnsupportedOperator{o.Attribute, o.Operator} 79 | } 80 | return result, err 81 | } 82 | 83 | // cmpTime performs time comparison on a and b. 84 | func cmpTime(o *Opts, a, b interface{}) (result bool, err error) { 85 | switch o.Operator { 86 | case tokenizer.Equals: 87 | result = a.(time.Time).Equal(b.(time.Time)) 88 | case tokenizer.NotEquals: 89 | result = !a.(time.Time).Equal(b.(time.Time)) 90 | case tokenizer.GreaterThanEquals: 91 | result = a.(time.Time).After(b.(time.Time)) || a.(time.Time).Equal(b.(time.Time)) 92 | case tokenizer.GreaterThan: 93 | result = a.(time.Time).After(b.(time.Time)) 94 | case tokenizer.LessThanEquals: 95 | result = a.(time.Time).Before(b.(time.Time)) || a.(time.Time).Equal(b.(time.Time)) 96 | case tokenizer.LessThan: 97 | result = a.(time.Time).Before(b.(time.Time)) 98 | case tokenizer.In: 99 | if _, ok := b.(map[interface{}]bool)[a.(time.Time)]; ok { 100 | result = true 101 | } 102 | default: 103 | err = &ErrUnsupportedOperator{o.Attribute, o.Operator} 104 | } 105 | return result, err 106 | } 107 | 108 | // cmpMode performs mode comparison with info and typ. 109 | func cmpMode(o *Opts) (result bool, err error) { 110 | if o.Operator != tokenizer.Is { 111 | return false, &ErrUnsupportedOperator{o.Attribute, o.Operator} 112 | } 113 | switch strings.ToUpper(o.Value.(string)) { 114 | case "DIR": 115 | result = o.File.Mode().IsDir() 116 | case "REG": 117 | result = o.File.Mode().IsRegular() 118 | default: 119 | result = false 120 | } 121 | return result, err 122 | } 123 | 124 | // cmpHash computes the hash of the current file and compares it with the 125 | // provided value. 126 | func cmpHash(o *Opts) (result bool, err error) { 127 | hashType := "SHA1" 128 | if len(o.Modifiers) > 0 { 129 | hashType = o.Modifiers[0].Name 130 | } 131 | 132 | hashFunc := transform.FindHash(hashType) 133 | if hashFunc == nil { 134 | return false, fmt.Errorf("unexpected hash algorithm %s", hashType) 135 | } 136 | h, err := transform.ComputeHash(o.File, o.Path, hashFunc()) 137 | if err != nil { 138 | return false, err 139 | } 140 | 141 | switch o.Operator { 142 | case tokenizer.Equals: 143 | result = h == o.Value 144 | case tokenizer.NotEquals: 145 | result = h != o.Value 146 | default: 147 | err = &ErrUnsupportedOperator{o.Attribute, o.Operator} 148 | } 149 | return result, err 150 | } 151 | -------------------------------------------------------------------------------- /evaluate/compare_test.go: -------------------------------------------------------------------------------- 1 | package evaluate 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "testing" 7 | "time" 8 | 9 | "github.com/kashav/fsql/tokenizer" 10 | ) 11 | 12 | func TestCmpAlpha(t *testing.T) { 13 | type Input struct { 14 | o Opts 15 | a, b interface{} 16 | } 17 | 18 | type Expected struct { 19 | result bool 20 | err error 21 | } 22 | 23 | type Case struct { 24 | input Input 25 | expected Expected 26 | } 27 | 28 | // TODO: Test for errors. 29 | cases := []Case{ 30 | { 31 | input: Input{o: Opts{Operator: tokenizer.Equals}, a: "a", b: "a"}, 32 | expected: Expected{result: true, err: nil}, 33 | }, 34 | { 35 | input: Input{o: Opts{Operator: tokenizer.Equals}, a: "a", b: "b"}, 36 | expected: Expected{result: false, err: nil}, 37 | }, 38 | { 39 | input: Input{o: Opts{Operator: tokenizer.Equals}, a: "a", b: "A"}, 40 | expected: Expected{result: false, err: nil}, 41 | }, 42 | 43 | { 44 | input: Input{o: Opts{Operator: tokenizer.NotEquals}, a: "a", b: "a"}, 45 | expected: Expected{result: false, err: nil}, 46 | }, 47 | { 48 | input: Input{o: Opts{Operator: tokenizer.NotEquals}, a: "a", b: "b"}, 49 | expected: Expected{result: true, err: nil}, 50 | }, 51 | { 52 | input: Input{o: Opts{Operator: tokenizer.NotEquals}, a: "a", b: "A"}, 53 | expected: Expected{result: true, err: nil}, 54 | }, 55 | 56 | { 57 | input: Input{o: Opts{Operator: tokenizer.Like}, a: "abc", b: "%a%"}, 58 | expected: Expected{result: true, err: nil}, 59 | }, 60 | { 61 | input: Input{o: Opts{Operator: tokenizer.Like}, a: "aaa", b: "%b%"}, 62 | expected: Expected{result: false, err: nil}, 63 | }, 64 | { 65 | input: Input{o: Opts{Operator: tokenizer.Like}, a: "aaa", b: "%a"}, 66 | expected: Expected{result: true, err: nil}, 67 | }, 68 | { 69 | input: Input{o: Opts{Operator: tokenizer.Like}, a: "abc", b: "%a"}, 70 | expected: Expected{result: false, err: nil}, 71 | }, 72 | { 73 | input: Input{o: Opts{Operator: tokenizer.Like}, a: "abc", b: "a%"}, 74 | expected: Expected{result: true, err: nil}, 75 | }, 76 | { 77 | input: Input{o: Opts{Operator: tokenizer.Like}, a: "cba", b: "a%"}, 78 | expected: Expected{result: false, err: nil}, 79 | }, 80 | { 81 | input: Input{o: Opts{Operator: tokenizer.Like}, a: "a", b: "a"}, 82 | expected: Expected{result: true, err: nil}, 83 | }, 84 | { 85 | input: Input{o: Opts{Operator: tokenizer.Like}, a: "a", b: "b"}, 86 | expected: Expected{result: false, err: nil}, 87 | }, 88 | 89 | { 90 | input: Input{o: Opts{Operator: tokenizer.RLike}, a: "a", b: ".*a.*"}, 91 | expected: Expected{result: true, err: nil}, 92 | }, 93 | { 94 | input: Input{o: Opts{Operator: tokenizer.RLike}, a: "a", b: "^$"}, 95 | expected: Expected{result: false, err: nil}, 96 | }, 97 | { 98 | input: Input{o: Opts{Operator: tokenizer.RLike}, a: "", b: "^$"}, 99 | expected: Expected{result: true, err: nil}, 100 | }, 101 | { 102 | input: Input{o: Opts{Operator: tokenizer.RLike}, a: "...", b: "[\\.]{3}"}, 103 | expected: Expected{result: true, err: nil}, 104 | }, 105 | { 106 | input: Input{o: Opts{Operator: tokenizer.RLike}, a: "aaa", b: "\\s+"}, 107 | expected: Expected{result: false, err: nil}, 108 | }, 109 | 110 | { 111 | input: Input{o: Opts{Operator: tokenizer.In}, a: "a", b: map[interface{}]bool{"a": true}}, 112 | expected: Expected{result: true, err: nil}, 113 | }, 114 | { 115 | input: Input{o: Opts{Operator: tokenizer.In}, a: "a", b: map[interface{}]bool{"a": false}}, 116 | expected: Expected{result: true, err: nil}, 117 | }, 118 | { 119 | input: Input{o: Opts{Operator: tokenizer.In}, a: "a", b: map[interface{}]bool{"b": true}}, 120 | expected: Expected{result: false, err: nil}, 121 | }, 122 | { 123 | input: Input{o: Opts{Operator: tokenizer.In}, a: "a", b: map[interface{}]bool{}}, 124 | expected: Expected{result: false, err: nil}, 125 | }, 126 | } 127 | 128 | for _, c := range cases { 129 | actual, err := cmpAlpha(&c.input.o, c.input.a, c.input.b) 130 | if c.expected.err == nil { 131 | if err != nil { 132 | t.Fatalf("\nExpected no error\n Got %v", err) 133 | } 134 | if !reflect.DeepEqual(c.expected.result, actual) { 135 | t.Fatalf("%v, %v, %v\nExpected: %v\n Got: %v", 136 | c.input.o.Operator, c.input.a, c.input.b, c.expected.result, actual) 137 | } 138 | } else if !reflect.DeepEqual(c.expected.err, err) { 139 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 140 | } 141 | } 142 | } 143 | 144 | func TestCmpNumeric(t *testing.T) { 145 | type Input struct { 146 | o Opts 147 | a, b interface{} 148 | } 149 | 150 | type Expected struct { 151 | result bool 152 | err error 153 | } 154 | 155 | type Case struct { 156 | input Input 157 | expected Expected 158 | } 159 | 160 | // TODO: Test for errors. 161 | cases := []Case{ 162 | { 163 | input: Input{o: Opts{Operator: tokenizer.Equals}, a: int64(1), b: int64(1)}, 164 | expected: Expected{result: true, err: nil}, 165 | }, 166 | { 167 | input: Input{o: Opts{Operator: tokenizer.Equals}, a: int64(1), b: int64(2)}, 168 | expected: Expected{result: false, err: nil}, 169 | }, 170 | 171 | { 172 | input: Input{o: Opts{Operator: tokenizer.NotEquals}, a: int64(1), b: int64(1)}, 173 | expected: Expected{result: false, err: nil}, 174 | }, 175 | { 176 | input: Input{o: Opts{Operator: tokenizer.NotEquals}, a: int64(1), b: int64(2)}, 177 | expected: Expected{result: true, err: nil}, 178 | }, 179 | 180 | { 181 | input: Input{o: Opts{Operator: tokenizer.GreaterThanEquals}, a: int64(1), b: int64(1)}, 182 | expected: Expected{result: true, err: nil}, 183 | }, 184 | { 185 | input: Input{o: Opts{Operator: tokenizer.GreaterThanEquals}, a: int64(2), b: int64(1)}, 186 | expected: Expected{result: true, err: nil}, 187 | }, 188 | { 189 | input: Input{o: Opts{Operator: tokenizer.GreaterThanEquals}, a: int64(1), b: int64(2)}, 190 | expected: Expected{result: false, err: nil}, 191 | }, 192 | 193 | { 194 | input: Input{o: Opts{Operator: tokenizer.GreaterThan}, a: int64(1), b: int64(1)}, 195 | expected: Expected{result: false, err: nil}, 196 | }, 197 | { 198 | input: Input{o: Opts{Operator: tokenizer.GreaterThan}, a: int64(2), b: int64(1)}, 199 | expected: Expected{result: true, err: nil}, 200 | }, 201 | { 202 | input: Input{o: Opts{Operator: tokenizer.GreaterThan}, a: int64(1), b: int64(2)}, 203 | expected: Expected{result: false, err: nil}, 204 | }, 205 | 206 | { 207 | input: Input{o: Opts{Operator: tokenizer.LessThanEquals}, a: int64(1), b: int64(1)}, 208 | expected: Expected{result: true, err: nil}, 209 | }, 210 | { 211 | input: Input{o: Opts{Operator: tokenizer.LessThanEquals}, a: int64(2), b: int64(1)}, 212 | expected: Expected{result: false, err: nil}, 213 | }, 214 | { 215 | input: Input{o: Opts{Operator: tokenizer.LessThanEquals}, a: int64(1), b: int64(2)}, 216 | expected: Expected{result: true, err: nil}, 217 | }, 218 | 219 | { 220 | input: Input{o: Opts{Operator: tokenizer.LessThan}, a: int64(1), b: int64(1)}, 221 | expected: Expected{result: false, err: nil}, 222 | }, 223 | { 224 | input: Input{o: Opts{Operator: tokenizer.LessThan}, a: int64(2), b: int64(1)}, 225 | expected: Expected{result: false, err: nil}, 226 | }, 227 | { 228 | input: Input{o: Opts{Operator: tokenizer.LessThan}, a: int64(1), b: int64(2)}, 229 | expected: Expected{result: true, err: nil}, 230 | }, 231 | 232 | { 233 | input: Input{o: Opts{Operator: tokenizer.In}, a: int64(1), b: map[interface{}]bool{int64(1): true}}, 234 | expected: Expected{result: true, err: nil}, 235 | }, 236 | { 237 | input: Input{o: Opts{Operator: tokenizer.In}, a: int64(1), b: map[interface{}]bool{int64(1): false}}, 238 | expected: Expected{result: true, err: nil}, 239 | }, 240 | { 241 | input: Input{o: Opts{Operator: tokenizer.In}, a: int64(1), b: map[interface{}]bool{int64(2): true}}, 242 | expected: Expected{result: false, err: nil}, 243 | }, 244 | { 245 | input: Input{o: Opts{Operator: tokenizer.In}, a: int64(1), b: map[interface{}]bool{}}, 246 | expected: Expected{result: false, err: nil}, 247 | }, 248 | } 249 | 250 | for _, c := range cases { 251 | actual, err := cmpNumeric(&c.input.o, c.input.a, c.input.b) 252 | if c.expected.err == nil { 253 | if err != nil { 254 | t.Fatalf("\nExpected no error\n Got %v", err) 255 | } 256 | if !reflect.DeepEqual(c.expected.result, actual) { 257 | t.Fatalf("%v, %v, %v\nExpected: %v\n Got: %v", 258 | c.input.o.Operator, c.input.a, c.input.b, c.expected.result, actual) 259 | } 260 | } else if !reflect.DeepEqual(c.expected.err, err) { 261 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 262 | } 263 | } 264 | } 265 | 266 | func TestCmpTime(t *testing.T) { 267 | type Input struct { 268 | o Opts 269 | a, b interface{} 270 | } 271 | 272 | type Expected struct { 273 | result bool 274 | err error 275 | } 276 | 277 | type Case struct { 278 | input Input 279 | expected Expected 280 | } 281 | 282 | // TODO: Test for errors. 283 | cases := []Case{ 284 | { 285 | input: Input{ 286 | o: Opts{Operator: tokenizer.Equals}, 287 | a: time.Now().Round(time.Minute), 288 | b: time.Now().Round(time.Minute), 289 | }, 290 | expected: Expected{result: true, err: nil}, 291 | }, 292 | { 293 | input: Input{ 294 | o: Opts{Operator: tokenizer.Equals}, 295 | a: time.Now().Add(time.Hour), 296 | b: time.Now().Add(-1 * time.Hour), 297 | }, 298 | expected: Expected{result: false, err: nil}, 299 | }, 300 | 301 | { 302 | input: Input{ 303 | o: Opts{Operator: tokenizer.NotEquals}, 304 | a: time.Now().Round(time.Minute), 305 | b: time.Now().Round(time.Minute), 306 | }, 307 | expected: Expected{result: false, err: nil}, 308 | }, 309 | { 310 | input: Input{ 311 | o: Opts{Operator: tokenizer.NotEquals}, 312 | a: time.Now().Add(time.Hour), 313 | b: time.Now().Add(-1 * time.Hour), 314 | }, 315 | expected: Expected{result: true, err: nil}, 316 | }, 317 | 318 | { 319 | input: Input{ 320 | o: Opts{Operator: tokenizer.In}, 321 | a: time.Now().Round(time.Minute), 322 | b: map[interface{}]bool{time.Now().Round(time.Minute): true}, 323 | }, 324 | expected: Expected{result: true, err: nil}, 325 | }, 326 | { 327 | input: Input{ 328 | o: Opts{Operator: tokenizer.In}, 329 | a: time.Now().Round(time.Minute), 330 | b: map[interface{}]bool{time.Now().Add(time.Hour): true}, 331 | }, 332 | expected: Expected{result: false, err: nil}, 333 | }, 334 | } 335 | 336 | for _, c := range cases { 337 | actual, err := cmpTime(&c.input.o, c.input.a, c.input.b) 338 | if c.expected.err == nil { 339 | if err != nil { 340 | t.Fatalf("\nExpected no error\n Got %v", err) 341 | } 342 | if !reflect.DeepEqual(c.expected.result, actual) { 343 | t.Fatalf("%v, %v, %v\nExpected: %v\n Got: %v", 344 | c.input.o.Operator, c.input.a, c.input.b, c.expected.result, actual) 345 | } 346 | } else if !reflect.DeepEqual(c.expected.err, err) { 347 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 348 | } 349 | } 350 | 351 | } 352 | 353 | func TestCmpMode(t *testing.T) { 354 | type Input struct { 355 | o Opts 356 | file os.FileInfo 357 | typ interface{} 358 | } 359 | 360 | type Expected struct { 361 | result bool 362 | err error 363 | } 364 | 365 | type Case struct { 366 | input Input 367 | expected Expected 368 | } 369 | 370 | // TODO: Complete these cases. 371 | cases := []Case{} 372 | 373 | for _, c := range cases { 374 | actual, err := cmpMode(&c.input.o) 375 | if c.expected.err == nil { 376 | if err != nil { 377 | t.Fatalf("\nExpected no error\n Got %v", err) 378 | } 379 | if !reflect.DeepEqual(c.expected.result, actual) { 380 | t.Fatalf("%v, %v, %v\nExpected: %v\n Got: %v", 381 | c.input.o.Operator, c.input.file, c.input.typ, c.expected.result, actual) 382 | } 383 | } else if !reflect.DeepEqual(c.expected.err, err) { 384 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 385 | } 386 | } 387 | } 388 | 389 | func TestCmpHash(t *testing.T) { 390 | type Input struct { 391 | o Opts 392 | file os.FileInfo 393 | typ interface{} 394 | } 395 | 396 | type Expected struct { 397 | result bool 398 | err error 399 | } 400 | 401 | type Case struct { 402 | input Input 403 | expected Expected 404 | } 405 | 406 | // TODO: Complete these cases. 407 | cases := []Case{} 408 | 409 | for _, c := range cases { 410 | actual, err := cmpHash(&c.input.o) 411 | if c.expected.err == nil { 412 | if err != nil { 413 | t.Fatalf("\nExpected no error\n Got %v", err) 414 | } 415 | if !reflect.DeepEqual(c.expected.result, actual) { 416 | t.Fatalf("%v, %v, %v\nExpected: %v\n Got: %v", 417 | c.input.o.Operator, c.input.file, c.input.typ, c.expected.result, actual) 418 | } 419 | } else if !reflect.DeepEqual(c.expected.err, err) { 420 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 421 | } 422 | } 423 | } 424 | -------------------------------------------------------------------------------- /evaluate/error.go: -------------------------------------------------------------------------------- 1 | package evaluate 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/kashav/fsql/tokenizer" 7 | ) 8 | 9 | // ErrUnsupportedAttribute represents an unsupported attribute error. 10 | type ErrUnsupportedAttribute struct { 11 | Attribute string 12 | } 13 | 14 | func (e *ErrUnsupportedAttribute) Error() string { 15 | return fmt.Sprintf("unsupported attribute %s", e.Attribute) 16 | } 17 | 18 | // ErrUnsupportedType represents an unsupported type error. 19 | type ErrUnsupportedType struct { 20 | Attribute string 21 | Value interface{} 22 | } 23 | 24 | func (e *ErrUnsupportedType) Error() string { 25 | return fmt.Sprintf("unsupported type %T for for attribute %s", e.Value, 26 | e.Attribute) 27 | } 28 | 29 | // ErrUnsupportedOperator represents an unsupported operator error. 30 | type ErrUnsupportedOperator struct { 31 | Attribute string 32 | Operator tokenizer.TokenType 33 | } 34 | 35 | func (e *ErrUnsupportedOperator) Error() string { 36 | return fmt.Sprintf("unsupported operator %s for attribute %s", 37 | e.Operator.String(), e.Attribute) 38 | } 39 | -------------------------------------------------------------------------------- /evaluate/error_test.go: -------------------------------------------------------------------------------- 1 | package evaluate 2 | 3 | // TODO 4 | -------------------------------------------------------------------------------- /evaluate/evaluate.go: -------------------------------------------------------------------------------- 1 | package evaluate 2 | 3 | import ( 4 | "os" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/kashav/fsql/tokenizer" 9 | ) 10 | 11 | // Opts represents a set of options used in the evaluate functions. 12 | type Opts struct { 13 | Path string 14 | File os.FileInfo 15 | Attribute string 16 | Modifiers []Modifier 17 | Operator tokenizer.TokenType 18 | Value interface{} 19 | } 20 | 21 | // Modifier represents an attribute modifier. 22 | type Modifier struct { 23 | Name string 24 | Arguments []string 25 | } 26 | 27 | // Evaluate runs the respective evaluate function for the provided options. 28 | func Evaluate(o *Opts) (bool, error) { 29 | switch o.Attribute { 30 | case "name": 31 | return evaluateName(o) 32 | case "size": 33 | return evaluateSize(o) 34 | case "time": 35 | return evaluateTime(o) 36 | case "mode": 37 | return evaluateMode(o) 38 | case "hash": 39 | return evaluateHash(o) 40 | } 41 | return false, &ErrUnsupportedAttribute{o.Attribute} 42 | } 43 | 44 | // evaluateName evaluates a Condition with attribute `name`. 45 | func evaluateName(o *Opts) (bool, error) { 46 | var a, b interface{} 47 | switch o.Value.(type) { 48 | case string, []string, map[interface{}]bool: 49 | a = o.File.Name() 50 | b = o.Value 51 | default: 52 | return false, &ErrUnsupportedType{o.Attribute, o.Value} 53 | } 54 | return cmpAlpha(o, a, b) 55 | } 56 | 57 | // evaluateSize evaluates a Condition with attribute `size`. 58 | func evaluateSize(o *Opts) (bool, error) { 59 | var a, b interface{} 60 | switch o.Value.(type) { 61 | case float64: 62 | a = o.File.Size() 63 | b = int64(o.Value.(float64)) 64 | case map[interface{}]bool: 65 | a = o.File.Size() 66 | b = o.Value 67 | case string: 68 | size, err := strconv.ParseFloat(o.Value.(string), 10) 69 | if err != nil { 70 | return false, err 71 | } 72 | a = o.File.Size() 73 | b = int64(size) 74 | default: 75 | return false, &ErrUnsupportedType{o.Attribute, o.Value} 76 | } 77 | return cmpNumeric(o, a, b) 78 | } 79 | 80 | // evaluateTime evaluates a Condition with attribute `time`. 81 | func evaluateTime(o *Opts) (bool, error) { 82 | var a, b interface{} 83 | switch o.Value.(type) { 84 | case string: 85 | t, err := time.Parse("Jan 02 2006 15 04", o.Value.(string)) 86 | if err != nil { 87 | return false, err 88 | } 89 | a = o.File.ModTime() 90 | b = t 91 | case map[interface{}]bool, time.Time: 92 | a = o.File.ModTime() 93 | b = o.Value 94 | default: 95 | return false, &ErrUnsupportedType{o.Attribute, o.Value} 96 | } 97 | return cmpTime(o, a, b) 98 | } 99 | 100 | // evaluateMode evaluates a Condition with attribute `mode`. 101 | func evaluateMode(o *Opts) (bool, error) { return cmpMode(o) } 102 | 103 | // evaluateHash evaluates a Condition with attribute `hash`. 104 | func evaluateHash(o *Opts) (bool, error) { return cmpHash(o) } 105 | -------------------------------------------------------------------------------- /evaluate/evaluate_test.go: -------------------------------------------------------------------------------- 1 | package evaluate 2 | 3 | // TODO 4 | -------------------------------------------------------------------------------- /fsql.go: -------------------------------------------------------------------------------- 1 | package fsql 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/kashav/fsql/parser" 9 | ) 10 | 11 | // Run parses the input and executes the resultant query. 12 | func Run(input string) error { 13 | q, err := parser.Run(input) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | // Find length of the longest name to normalize name output. 19 | var max = 0 20 | var results = make([]map[string]interface{}, 0) 21 | 22 | err = q.Execute( 23 | func(path string, info os.FileInfo, result map[string]interface{}) { 24 | results = append(results, result) 25 | if !q.HasAttribute("name") { 26 | return 27 | } 28 | if s, ok := result["name"].(string); ok && len(s) > max { 29 | max = len(s) 30 | } 31 | }, 32 | ) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | for _, result := range results { 38 | var buf bytes.Buffer 39 | for j, attribute := range q.Attributes { 40 | // If the current attribute is "name", pad the output string by `max` 41 | // spaces. 42 | format := "%v" 43 | if attribute == "name" { 44 | format = fmt.Sprintf("%%-%ds", max) 45 | } 46 | buf.WriteString(fmt.Sprintf(format, result[attribute])) 47 | if j != len(q.Attributes)-1 { 48 | buf.WriteString("\t") 49 | } 50 | } 51 | fmt.Printf("%s\n", buf.String()) 52 | } 53 | 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /fsql_test.go: -------------------------------------------------------------------------------- 1 | package fsql 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha1" 6 | "encoding/hex" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "reflect" 12 | "strings" 13 | "testing" 14 | "time" 15 | ) 16 | 17 | var files = map[string]*os.FileInfo{} 18 | 19 | func TestRun_All(t *testing.T) { 20 | type Case struct { 21 | query string 22 | expected string 23 | } 24 | 25 | cases := []Case{ 26 | { 27 | query: "SELECT all FROM ./testdata WHERE name = foo", 28 | expected: fmt.Sprintf("%s\t-------\tfoo\n", 29 | strings.Join(GetAttrs("foo", "mode", "size", "time"), "\t")), 30 | }, 31 | { 32 | query: "SELECT all FROM ./testdata WHERE name LIKE waldo", 33 | expected: fmt.Sprintf("%s\twaldo\n", 34 | strings.Join(GetAttrs("foo/quuz/waldo", "mode", "size", "time", "hash"), "\t")), 35 | }, 36 | { 37 | query: "SELECT all FROM ./testdata WHERE FORMAT(time, 'Jan 02 2006 15:04') > 'Jan 01 2999 00:00'", 38 | expected: "", 39 | }, 40 | { 41 | query: "SELECT all FROM ./testdata WHERE mode IS DIR", 42 | expected: fmt.Sprintf( 43 | strings.Repeat("%s\n", 8), 44 | fmt.Sprintf( 45 | "%s\t-------\t%-8s", 46 | strings.Join(GetAttrs(".", "mode", "size", "time"), "\t"), 47 | "testdata", 48 | ), 49 | fmt.Sprintf( 50 | "%s\t-------\t%-8s", 51 | strings.Join(GetAttrs("bar", "mode", "size", "time"), "\t"), 52 | "bar", 53 | ), 54 | fmt.Sprintf( 55 | "%s\t-------\t%-8s", 56 | strings.Join(GetAttrs("bar/garply", "mode", "size", "time"), "\t"), 57 | "garply", 58 | ), 59 | fmt.Sprintf( 60 | "%s\t-------\t%-8s", 61 | strings.Join(GetAttrs("bar/garply/xyzzy", "mode", "size", "time"), "\t"), 62 | "xyzzy", 63 | ), 64 | fmt.Sprintf( 65 | "%s\t-------\t%-8s", 66 | strings.Join(GetAttrs("bar/garply/xyzzy/thud", "mode", "size", "time"), "\t"), 67 | "thud", 68 | ), 69 | fmt.Sprintf( 70 | "%s\t-------\t%-8s", 71 | strings.Join(GetAttrs("foo", "mode", "size", "time"), "\t"), 72 | "foo", 73 | ), 74 | fmt.Sprintf( 75 | "%s\t-------\t%-8s", 76 | strings.Join(GetAttrs("foo/quuz", "mode", "size", "time"), "\t"), 77 | "quuz", 78 | ), 79 | fmt.Sprintf( 80 | "%s\t-------\t%-8s", 81 | strings.Join(GetAttrs("foo/quuz/fred", "mode", "size", "time"), "\t"), 82 | "fred", 83 | ), 84 | ), 85 | }, 86 | } 87 | 88 | for _, c := range cases { 89 | actual := DoRun(c.query) 90 | if !reflect.DeepEqual(c.expected, actual) { 91 | t.Fatalf("%s\nExpected:\n%v\nGot:\n%v", c.query, c.expected, actual) 92 | } 93 | } 94 | } 95 | 96 | func TestRun_Multiple(t *testing.T) { 97 | type Case struct { 98 | query string 99 | expected string 100 | } 101 | 102 | cases := []Case{ 103 | { 104 | query: "SELECT name, size FROM ./testdata WHERE name = foo", 105 | expected: fmt.Sprintf("foo\t%s\n", GetAttrs("foo", "size")[0]), 106 | }, 107 | { 108 | query: "SELECT size, name FROM ./testdata WHERE name = foo", 109 | expected: fmt.Sprintf("%s\tfoo\n", GetAttrs("foo", "size")[0]), 110 | }, 111 | { 112 | query: "SELECT FULLPATH(name), size, time FROM ./testdata/foo", 113 | expected: fmt.Sprintf( 114 | strings.Repeat("%s\n", 7), 115 | fmt.Sprintf( 116 | "%-31s\t%s", 117 | "testdata/foo", 118 | strings.Join(GetAttrs("foo", "size", "time"), "\t"), 119 | ), 120 | fmt.Sprintf( 121 | "%-31s\t%s", 122 | "testdata/foo/quux", 123 | strings.Join(GetAttrs("foo/quux", "size", "time"), "\t"), 124 | ), 125 | fmt.Sprintf( 126 | "%-31s\t%s", 127 | "testdata/foo/quuz", 128 | strings.Join(GetAttrs("foo/quuz", "size", "time"), "\t"), 129 | ), 130 | fmt.Sprintf( 131 | "%-31s\t%s", 132 | "testdata/foo/quuz/fred", 133 | strings.Join(GetAttrs("foo/quuz/fred", "size", "time"), "\t"), 134 | ), 135 | fmt.Sprintf( 136 | "%-31s\t%s", 137 | "testdata/foo/quuz/fred/.gitkeep", 138 | strings.Join(GetAttrs("foo/quuz/fred/.gitkeep", "size", "time"), "\t"), 139 | ), 140 | fmt.Sprintf( 141 | "%-31s\t%s", 142 | "testdata/foo/quuz/waldo", 143 | strings.Join(GetAttrs("foo/quuz/waldo", "size", "time"), "\t"), 144 | ), 145 | fmt.Sprintf( 146 | "%-31s\t%s", 147 | "testdata/foo/qux", 148 | strings.Join(GetAttrs("foo/qux", "size", "time"), "\t"), 149 | ), 150 | ), 151 | }, 152 | } 153 | 154 | for _, c := range cases { 155 | actual := DoRun(c.query) 156 | if !reflect.DeepEqual(c.expected, actual) { 157 | t.Fatalf("%s\nExpected:\n%v\nGot:\n%v", c.query, c.expected, actual) 158 | } 159 | } 160 | } 161 | 162 | func TestRun_Name(t *testing.T) { 163 | type Case struct { 164 | query string 165 | expected string 166 | } 167 | 168 | cases := []Case{ 169 | { 170 | query: "SELECT name FROM ./testdata WHERE name REGEXP ^g.*", 171 | expected: "garply\ngrault\n", 172 | }, 173 | { 174 | query: "SELECT FULLPATH(name) FROM ./testdata WHERE name REGEXP ^b.*", 175 | expected: "testdata/bar\ntestdata/baz\n", 176 | }, 177 | { 178 | query: "SELECT UPPER(FULLPATH(name)) FROM ./testdata WHERE mode IS DIR", 179 | expected: fmt.Sprintf( 180 | strings.Repeat("%-30s\n", 8), 181 | "TESTDATA", 182 | "TESTDATA/BAR", 183 | "TESTDATA/BAR/GARPLY", 184 | "TESTDATA/BAR/GARPLY/XYZZY", 185 | "TESTDATA/BAR/GARPLY/XYZZY/THUD", 186 | "TESTDATA/FOO", 187 | "TESTDATA/FOO/QUUZ", 188 | "TESTDATA/FOO/QUUZ/FRED", 189 | ), 190 | }, 191 | } 192 | 193 | for _, c := range cases { 194 | actual := DoRun(c.query) 195 | if !reflect.DeepEqual(c.expected, actual) { 196 | t.Fatalf("%s\nExpected:\n%v\nGot:\n%v", c.query, c.expected, actual) 197 | } 198 | } 199 | } 200 | 201 | func TestRun_Size(t *testing.T) { 202 | type Case struct { 203 | query string 204 | expected string 205 | } 206 | 207 | cases := []Case{ 208 | { 209 | query: "SELECT size FROM ./testdata WHERE name = foo", 210 | expected: fmt.Sprintf("%s\n", GetAttrs("foo", "size")[0]), 211 | }, 212 | { 213 | query: "SELECT FORMAT(size, KB) FROM ./testdata WHERE name = foo", 214 | expected: fmt.Sprintf("%s\n", GetAttrs("foo", "size:kb")[0]), 215 | }, 216 | { 217 | query: "SELECT FORMAT(size, MB) FROM ./testdata WHERE name = foo", 218 | expected: fmt.Sprintf("%s\n", GetAttrs("foo", "size:mb")[0]), 219 | }, 220 | { 221 | query: "SELECT FORMAT(size, GB) FROM ./testdata WHERE name = foo", 222 | expected: fmt.Sprintf("%s\n", GetAttrs("foo", "size:gb")[0]), 223 | }, 224 | { 225 | query: "SELECT size FROM ./testdata WHERE name LIKE qu", 226 | expected: fmt.Sprintf( 227 | strings.Repeat("%s\n", 3), 228 | GetAttrs("foo/quux", "size")[0], 229 | GetAttrs("foo/quuz", "size")[0], 230 | GetAttrs("foo/qux", "size")[0], 231 | ), 232 | }, 233 | } 234 | 235 | for _, c := range cases { 236 | actual := DoRun(c.query) 237 | if !reflect.DeepEqual(c.expected, actual) { 238 | t.Fatalf("%s\nExpected:\n%v\nGot:\n%v", c.query, c.expected, actual) 239 | } 240 | } 241 | } 242 | 243 | func TestRun_Time(t *testing.T) { 244 | type Case struct { 245 | query string 246 | expected string 247 | } 248 | 249 | cases := []Case{ 250 | { 251 | query: "SELECT time FROM ./testdata WHERE name = baz", 252 | expected: fmt.Sprintf("%s\n", GetAttrs("baz", "time")[0]), 253 | }, 254 | { 255 | query: "SELECT FORMAT(time, ISO) FROM ./testdata WHERE name = foo", 256 | expected: fmt.Sprintf("%s\n", GetAttrs("foo", "time:iso")[0]), 257 | }, 258 | { 259 | query: "SELECT FORMAT(time, 2006) FROM ./testdata WHERE NOT name LIKE .%", 260 | expected: strings.Repeat(fmt.Sprintf("%s\n", GetAttrs(".", "time:year")[0]), 14), 261 | }, 262 | { 263 | query: "SELECT time FROM ./testdata/foo/quuz", 264 | expected: fmt.Sprintf( 265 | strings.Repeat("%s\n", 4), 266 | GetAttrs("foo/quuz", "time")[0], 267 | GetAttrs("foo/quuz/fred", "time")[0], 268 | GetAttrs("foo/quuz/fred/.gitkeep", "time")[0], 269 | GetAttrs("foo/quuz/waldo", "time")[0], 270 | ), 271 | }, 272 | } 273 | 274 | for _, c := range cases { 275 | actual := DoRun(c.query) 276 | if !reflect.DeepEqual(c.expected, actual) { 277 | t.Fatalf("%s\nExpected:\n%v\nGot:\n%v", c.query, c.expected, actual) 278 | } 279 | } 280 | } 281 | 282 | func TestRun_Mode(t *testing.T) { 283 | type Case struct { 284 | query string 285 | expected string 286 | } 287 | 288 | cases := []Case{ 289 | { 290 | query: "SELECT mode FROM ./testdata WHERE name = foo", 291 | expected: fmt.Sprintf("%s\n", GetAttrs("foo", "mode")[0]), 292 | }, 293 | { 294 | query: "SELECT mode FROM ./testdata WHERE name = baz", 295 | expected: fmt.Sprintf("%s\n", GetAttrs("baz", "mode")[0]), 296 | }, 297 | { 298 | query: "SELECT mode FROM ./testdata WHERE mode IS DIR", 299 | expected: fmt.Sprintf(strings.Repeat("%s\n", 8), 300 | GetAttrs("", "mode")[0], 301 | GetAttrs("foo", "mode")[0], 302 | GetAttrs("foo/quuz", "mode")[0], 303 | GetAttrs("foo/quuz/fred", "mode")[0], 304 | GetAttrs("bar", "mode")[0], 305 | GetAttrs("bar/garply", "mode")[0], 306 | GetAttrs("bar/garply/xyzzy", "mode")[0], 307 | GetAttrs("bar/garply/xyzzy/thud", "mode")[0], 308 | ), 309 | }, 310 | } 311 | 312 | for _, c := range cases { 313 | actual := DoRun(c.query) 314 | if !reflect.DeepEqual(c.expected, actual) { 315 | t.Fatalf("%s\nExpected:\n\"%v\"\nGot:\n\"%v\"", c.query, c.expected, actual) 316 | } 317 | } 318 | } 319 | 320 | func TestRun_Hash(t *testing.T) { 321 | type Case struct { 322 | query string 323 | expected string 324 | } 325 | 326 | // TODO 327 | cases := []Case{} 328 | 329 | for _, c := range cases { 330 | actual := DoRun(c.query) 331 | if !reflect.DeepEqual(c.expected, actual) { 332 | t.Fatalf("%s\nExpected:\n%v\nGot:\n%v", c.query, c.expected, actual) 333 | } 334 | } 335 | } 336 | 337 | func GetAttrs(path string, attrs ...string) []string { 338 | // If the files map is empty, walk ./testdata and populate it. 339 | if len(files) == 0 { 340 | if err := filepath.Walk( 341 | "./testdata", 342 | func(path string, info os.FileInfo, err error) error { 343 | files[filepath.Clean(path)] = &info 344 | return nil 345 | }, 346 | ); err != nil { 347 | return []string{} 348 | } 349 | } 350 | 351 | path = filepath.Clean(fmt.Sprintf("testdata/%s", path)) 352 | file, ok := files[path] 353 | if !ok { 354 | return []string{} 355 | } 356 | 357 | result := make([]string, len(attrs)) 358 | for i, attr := range attrs { 359 | // Hard-coding modifiers works for the time being, but we might need a more 360 | // elegant solution when we introduce new modifiers in the future. 361 | switch attr { 362 | case "hash": 363 | b, err := os.ReadFile(path) 364 | if err != nil { 365 | return []string{} 366 | } 367 | h := sha1.New() 368 | if _, err := h.Write(b); err != nil { 369 | return []string{} 370 | } 371 | result[i] = hex.EncodeToString(h.Sum(nil))[:7] 372 | case "mode": 373 | result[i] = (*file).Mode().String() 374 | case "size": 375 | result[i] = fmt.Sprintf("%d", (*file).Size()) 376 | case "size:kb", "size:mb", "size:gb": 377 | size := (*file).Size() 378 | switch attr[len(attr)-2:] { 379 | case "kb": 380 | result[i] = fmt.Sprintf("%fkb", float64(size)/(1<<10)) 381 | case "mb": 382 | result[i] = fmt.Sprintf("%fmb", float64(size)/(1<<20)) 383 | case "gb": 384 | result[i] = fmt.Sprintf("%fgb", float64(size)/(1<<30)) 385 | } 386 | case "time": 387 | result[i] = (*file).ModTime().Format(time.Stamp) 388 | case "time:iso": 389 | result[i] = (*file).ModTime().Format(time.RFC3339) 390 | case "time:year": 391 | result[i] = (*file).ModTime().Format("2006") 392 | } 393 | } 394 | return result 395 | } 396 | 397 | // DoRun executes fsql.Run and returns the output. 398 | func DoRun(query string) string { 399 | stdout := os.Stdout 400 | ch := make(chan string) 401 | 402 | r, w, err := os.Pipe() 403 | if err != nil { 404 | return "" 405 | } 406 | os.Stdout = w 407 | 408 | if err := Run(query); err != nil { 409 | return "" 410 | } 411 | 412 | go func() { 413 | var buf bytes.Buffer 414 | io.Copy(&buf, r) 415 | ch <- buf.String() 416 | }() 417 | 418 | w.Close() 419 | os.Stdout = stdout 420 | return <-ch 421 | } 422 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kashav/fsql 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/oleiade/lane v1.0.1 7 | golang.org/x/crypto v0.17.0 8 | ) 9 | 10 | require ( 11 | golang.org/x/sys v0.15.0 // indirect 12 | golang.org/x/term v0.15.0 // indirect 13 | ) 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/oleiade/lane v1.0.1 h1:hXofkn7GEOubzTwNpeL9MaNy8WxolCYb9cInAIeqShU= 2 | github.com/oleiade/lane v1.0.1/go.mod h1:IyTkraa4maLfjq/GmHR+Dxb4kCMtEGeb+qmhlrQ5Mk4= 3 | golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= 4 | golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 5 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 6 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 7 | golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= 8 | golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= 9 | -------------------------------------------------------------------------------- /media/fsql.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kashav/fsql/27e06ef3550e5b7b45c8f95c68cfcf0e39a4ab48/media/fsql.gif -------------------------------------------------------------------------------- /meta/meta.go: -------------------------------------------------------------------------------- 1 | package meta 2 | 3 | import "fmt" 4 | 5 | // GITCOMMIT indicates which git hash the binary was built off of. 6 | var GITCOMMIT string 7 | 8 | // VERSION indicates which version of the binary is running. 9 | var VERSION string 10 | 11 | // Release holds the current release number, should match the value 12 | // in $GOPATH/src/github.com/kashav/fsql/VERSION. 13 | const Release = "0.5.2" 14 | 15 | // Meta returns the version/commit string. 16 | func Meta() string { 17 | version, commit := VERSION, GITCOMMIT 18 | if commit == "" || version == "" { 19 | version, commit = Release, "master" 20 | } 21 | return fmt.Sprintf("fsql version %v, built off %v", version, commit) 22 | } 23 | -------------------------------------------------------------------------------- /parser/attribute.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/kashav/fsql/query" 7 | "github.com/kashav/fsql/tokenizer" 8 | ) 9 | 10 | var allAttributes = []string{"mode", "size", "time", "hash", "name"} 11 | 12 | func isValidAttribute(attribute string) error { 13 | for _, valid := range allAttributes { 14 | if attribute == valid { 15 | return nil 16 | } 17 | } 18 | return &ErrUnknownToken{attribute} 19 | } 20 | 21 | // parseAttrs parses the list of attributes passed to the SELECT clause. 22 | func (p *parser) parseAttrs(attributes *[]string, modifiers *map[string][]query.Modifier) error { 23 | for { 24 | ident := p.expect(tokenizer.Identifier) 25 | if ident == nil { 26 | return p.currentError() 27 | } 28 | 29 | if ident.Raw == "*" || ident.Raw == "all" { 30 | *attributes = allAttributes 31 | } else { 32 | p.current = ident 33 | 34 | attrModifiers := make([]query.Modifier, 0) 35 | attribute, err := p.parseAttr(&attrModifiers) 36 | if err != nil { 37 | return err 38 | } 39 | *attributes = append(*attributes, attribute.Raw) 40 | (*modifiers)[attribute.Raw] = attrModifiers 41 | } 42 | 43 | if p.expect(tokenizer.Comma) == nil { 44 | break 45 | } 46 | } 47 | return nil 48 | } 49 | 50 | // parseAttr recursively parses an attribute's modifiers and returns the 51 | // associated attribute. 52 | func (p *parser) parseAttr(modifiers *[]query.Modifier) (*tokenizer.Token, error) { 53 | ident := p.expect(tokenizer.Identifier) 54 | if ident == nil { 55 | return nil, p.currentError() 56 | } 57 | 58 | // ident is a modifier name (e.g. `FORMAT`) iff the next token is an open 59 | // paren, otherwise an attribute (e.g. `name`). 60 | if token := p.expect(tokenizer.OpenParen); token == nil { 61 | if err := isValidAttribute(ident.Raw); err != nil { 62 | return nil, err 63 | } 64 | return ident, nil 65 | } 66 | 67 | // In the case of chained modifiers, we want to recurse and parse each 68 | // inner modifier first. parseAttribute returns the associated attribute that 69 | // we're looking for. 70 | attribute, err := p.parseAttr(modifiers) 71 | if err != nil { 72 | return nil, err 73 | } 74 | if attribute == nil { 75 | return nil, p.currentError() 76 | } 77 | if err := isValidAttribute(attribute.Raw); err != nil { 78 | return nil, err 79 | } 80 | 81 | modifier := query.Modifier{ 82 | Name: strings.ToUpper(ident.Raw), 83 | Arguments: make([]string, 0), 84 | } 85 | 86 | // Parse the modifier arguments. 87 | for { 88 | if token := p.expect(tokenizer.Identifier); token != nil { 89 | modifier.Arguments = append(modifier.Arguments, token.Raw) 90 | continue 91 | } 92 | 93 | if token := p.expect(tokenizer.Comma); token != nil { 94 | continue 95 | } 96 | 97 | if token := p.expect(tokenizer.CloseParen); token != nil { 98 | *modifiers = append(*modifiers, modifier) 99 | return attribute, nil 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /parser/attribute_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "io" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/kashav/fsql/query" 9 | "github.com/kashav/fsql/tokenizer" 10 | ) 11 | 12 | func TestAttributeParser_ExpectCorrectAttributes(t *testing.T) { 13 | type Expected struct { 14 | attributes []string 15 | err error 16 | } 17 | 18 | type Case struct { 19 | input string 20 | expected Expected 21 | } 22 | 23 | cases := []Case{ 24 | { 25 | input: "name", 26 | expected: Expected{attributes: []string{"name"}, err: nil}, 27 | }, 28 | { 29 | input: "name, size", 30 | expected: Expected{ 31 | attributes: []string{"name", "size"}, 32 | err: nil, 33 | }, 34 | }, 35 | { 36 | input: "*", 37 | expected: Expected{attributes: allAttributes, err: nil}, 38 | }, 39 | { 40 | input: "all", 41 | expected: Expected{attributes: allAttributes, err: nil}, 42 | }, 43 | { 44 | input: "format(time, iso)", 45 | expected: Expected{attributes: []string{"time"}, err: nil}, 46 | }, 47 | 48 | { 49 | input: "", 50 | expected: Expected{err: io.ErrUnexpectedEOF}, 51 | }, 52 | { 53 | input: "name,", 54 | expected: Expected{err: io.ErrUnexpectedEOF}, 55 | }, 56 | { 57 | input: "identifier", 58 | expected: Expected{err: &ErrUnknownToken{"identifier"}}, 59 | }, 60 | } 61 | 62 | for _, c := range cases { 63 | attributes := make([]string, 0) 64 | modifiers := make(map[string][]query.Modifier) 65 | 66 | p := &parser{tokenizer: tokenizer.NewTokenizer(c.input)} 67 | err := p.parseAttrs(&attributes, &modifiers) 68 | 69 | if c.expected.err == nil { 70 | if err != nil { 71 | t.Fatalf("\nExpected no error\n Got %v", err) 72 | } 73 | if !reflect.DeepEqual(c.expected.attributes, attributes) { 74 | t.Fatalf("\nExpected %v\n Got %v", c.expected.attributes, attributes) 75 | } 76 | } else if !reflect.DeepEqual(c.expected.err, err) { 77 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 78 | } 79 | } 80 | } 81 | 82 | func TestAttributeParser_ExpectCorrectModifiers(t *testing.T) { 83 | type Expected struct { 84 | modifiers map[string][]query.Modifier 85 | err error 86 | } 87 | type Case struct { 88 | input string 89 | expected Expected 90 | } 91 | 92 | cases := []Case{ 93 | { 94 | input: "name", 95 | expected: Expected{ 96 | modifiers: map[string][]query.Modifier{"name": {}}, 97 | err: nil, 98 | }, 99 | }, 100 | { 101 | input: "upper(name)", 102 | expected: Expected{ 103 | modifiers: map[string][]query.Modifier{ 104 | "name": { 105 | { 106 | Name: "UPPER", 107 | Arguments: []string{}, 108 | }, 109 | }, 110 | }, 111 | err: nil, 112 | }, 113 | }, 114 | { 115 | input: "format(time, iso)", 116 | expected: Expected{ 117 | modifiers: map[string][]query.Modifier{ 118 | "time": { 119 | { 120 | Name: "FORMAT", 121 | Arguments: []string{"iso"}, 122 | }, 123 | }, 124 | }, 125 | err: nil, 126 | }, 127 | }, 128 | { 129 | input: "format(time, \"iso\")", 130 | expected: Expected{ 131 | modifiers: map[string][]query.Modifier{ 132 | "time": { 133 | { 134 | Name: "FORMAT", 135 | Arguments: []string{"iso"}, 136 | }, 137 | }, 138 | }, 139 | err: nil, 140 | }, 141 | }, 142 | { 143 | input: "lower(name), format(size, mb)", 144 | expected: Expected{ 145 | modifiers: map[string][]query.Modifier{ 146 | "name": { 147 | { 148 | Name: "LOWER", 149 | Arguments: []string{}, 150 | }, 151 | }, 152 | "size": { 153 | { 154 | Name: "FORMAT", 155 | Arguments: []string{"mb"}, 156 | }, 157 | }, 158 | }, 159 | err: nil, 160 | }, 161 | }, 162 | { 163 | input: "format(fullpath(name), lower)", 164 | expected: Expected{ 165 | modifiers: map[string][]query.Modifier{ 166 | "name": { 167 | { 168 | Name: "FULLPATH", 169 | Arguments: []string{}, 170 | }, 171 | { 172 | Name: "FORMAT", 173 | Arguments: []string{"lower"}, 174 | }, 175 | }, 176 | }, 177 | err: nil, 178 | }, 179 | }, 180 | 181 | // No function/parameter validation yet! 182 | { 183 | input: "foo(name)", 184 | expected: Expected{ 185 | modifiers: map[string][]query.Modifier{ 186 | "name": {{ 187 | Name: "FOO", 188 | Arguments: []string{}, 189 | }, 190 | }, 191 | }, 192 | err: nil, 193 | }, 194 | }, 195 | { 196 | input: "format(size, tb)", 197 | expected: Expected{ 198 | modifiers: map[string][]query.Modifier{ 199 | "size": { 200 | { 201 | Name: "FORMAT", 202 | Arguments: []string{"tb"}, 203 | }, 204 | }, 205 | }, 206 | err: nil, 207 | }, 208 | }, 209 | { 210 | input: "format(size, kb, mb)", 211 | expected: Expected{ 212 | modifiers: map[string][]query.Modifier{ 213 | "size": { 214 | { 215 | Name: "FORMAT", 216 | Arguments: []string{"kb", "mb"}, 217 | }, 218 | }, 219 | }, 220 | err: nil, 221 | }, 222 | }, 223 | 224 | { 225 | input: "", 226 | expected: Expected{err: io.ErrUnexpectedEOF}, 227 | }, 228 | { 229 | input: "lower(name),", 230 | expected: Expected{err: io.ErrUnexpectedEOF}, 231 | }, 232 | { 233 | input: "identifier", 234 | expected: Expected{err: &ErrUnknownToken{"identifier"}}, 235 | }, 236 | } 237 | 238 | for _, c := range cases { 239 | attributes := make([]string, 0) 240 | modifiers := make(map[string][]query.Modifier) 241 | 242 | p := &parser{tokenizer: tokenizer.NewTokenizer(c.input)} 243 | err := p.parseAttrs(&attributes, &modifiers) 244 | 245 | if c.expected.err == nil { 246 | if err != nil { 247 | t.Fatalf("\nExpected no error\n Got %v", err) 248 | } 249 | if !reflect.DeepEqual(c.expected.modifiers, modifiers) { 250 | t.Fatalf("\nExpected %v\n Got %v", c.expected.modifiers, modifiers) 251 | } 252 | } else if !reflect.DeepEqual(c.expected.err, err) { 253 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 254 | } 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /parser/condition.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | 7 | "github.com/oleiade/lane" 8 | 9 | "github.com/kashav/fsql/query" 10 | "github.com/kashav/fsql/tokenizer" 11 | ) 12 | 13 | // parseConditionTree parses the condition tree passed to the WHERE clause. 14 | func (p *parser) parseConditionTree() (*query.ConditionNode, error) { 15 | stack := lane.NewStack() 16 | errFailedToParse := errors.New("failed to parse conditions") 17 | 18 | for { 19 | if p.current = p.tokenizer.Next(); p.current == nil { 20 | break 21 | } 22 | 23 | switch p.current.Type { 24 | 25 | case tokenizer.Not: 26 | // TODO: Handle NOT (...), for the time being we proceed with the other 27 | // tokens and handle the negation when parsing the condition. 28 | fallthrough 29 | 30 | case tokenizer.Identifier: 31 | condition, err := p.parseCondition() 32 | if err != nil { 33 | return nil, err 34 | } 35 | if condition == nil { 36 | return nil, p.currentError() 37 | } 38 | 39 | if condition.IsSubquery { 40 | if err := p.parseSubquery(condition); err != nil { 41 | return nil, err 42 | } 43 | } 44 | 45 | leafNode := &query.ConditionNode{Condition: condition} 46 | if prevNode, ok := stack.Pop().(*query.ConditionNode); !ok { 47 | stack.Push(leafNode) 48 | } else if prevNode.Condition == nil { 49 | prevNode.Right = leafNode 50 | stack.Push(prevNode) 51 | } else { 52 | return nil, errFailedToParse 53 | } 54 | 55 | case tokenizer.And, tokenizer.Or: 56 | leftNode, ok := stack.Pop().(*query.ConditionNode) 57 | if !ok { 58 | return nil, errFailedToParse 59 | } 60 | 61 | node := query.ConditionNode{ 62 | Type: &p.current.Type, 63 | Left: leftNode, 64 | } 65 | stack.Push(&node) 66 | 67 | case tokenizer.OpenParen: 68 | stack.Push(nil) 69 | 70 | case tokenizer.CloseParen: 71 | rightNode, ok := stack.Pop().(*query.ConditionNode) 72 | if !ok { 73 | return nil, errFailedToParse 74 | } 75 | 76 | if rootNode, ok := stack.Pop().(*query.ConditionNode); !ok { 77 | stack.Push(rightNode) 78 | } else { 79 | rootNode.Right = rightNode 80 | stack.Push(rootNode) 81 | } 82 | 83 | } 84 | } 85 | 86 | if stack.Size() == 0 { 87 | return nil, p.currentError() 88 | } 89 | 90 | if stack.Size() > 1 { 91 | return nil, errFailedToParse 92 | } 93 | 94 | node, ok := stack.Pop().(*query.ConditionNode) 95 | if !ok { 96 | return nil, errFailedToParse 97 | } 98 | return node, nil 99 | } 100 | 101 | // parseCondition parses and returns the next condition. 102 | func (p *parser) parseCondition() (*query.Condition, error) { 103 | cond := &query.Condition{} 104 | 105 | // If we find a NOT, negate the condition. 106 | if p.expect(tokenizer.Not) != nil { 107 | cond.Negate = true 108 | } 109 | 110 | ident := p.expect(tokenizer.Identifier) 111 | if ident == nil { 112 | return nil, p.currentError() 113 | } 114 | p.current = ident 115 | 116 | var modifiers []query.Modifier 117 | attr, err := p.parseAttr(&modifiers) 118 | if err != nil { 119 | return nil, err 120 | } 121 | cond.Attribute = attr.Raw 122 | cond.AttributeModifiers = modifiers 123 | 124 | // If this condition has modifiers, then p.current was unset while parsing 125 | // the modifier, se we set the current token manually. 126 | if len(modifiers) > 0 { 127 | p.current = p.tokenizer.Next() 128 | } 129 | if p.current == nil { 130 | return nil, p.currentError() 131 | } 132 | cond.Operator = p.current.Type 133 | p.current = nil 134 | 135 | // Parse subquery of format `(...)`. 136 | if p.expect(tokenizer.OpenParen) != nil { 137 | token := p.expect(tokenizer.Subquery) 138 | if token == nil { 139 | return nil, p.currentError() 140 | } 141 | cond.IsSubquery = true 142 | cond.Value = token.Raw 143 | if p.expect(tokenizer.CloseParen) == nil { 144 | return nil, p.currentError() 145 | } 146 | return cond, nil 147 | } 148 | 149 | // Parse list of values of format `[...]`. 150 | if p.expect(tokenizer.OpenBracket) != nil { 151 | values := make([]string, 0) 152 | for { 153 | if token := p.expect(tokenizer.Identifier); token != nil { 154 | values = append(values, token.Raw) 155 | } 156 | if p.expect(tokenizer.Comma) != nil { 157 | continue 158 | } 159 | if p.expect(tokenizer.CloseBracket) != nil { 160 | break 161 | } 162 | } 163 | cond.Value = values 164 | return cond, nil 165 | } 166 | 167 | // Not a list nor a subquery -> plain identifier! 168 | token := p.expect(tokenizer.Identifier) 169 | if token == nil { 170 | return nil, p.currentError() 171 | } 172 | cond.Value = token.Raw 173 | return cond, nil 174 | } 175 | 176 | // parseSubquery parses a subquery by recursively evaluating it's condition(s). 177 | // If the subquery contains references to aliases from the superquery, it's 178 | // Subquery attribute is set. Otherwise, we evaluate it's Subquery and set 179 | // it's Value to the result. 180 | func (p *parser) parseSubquery(condition *query.Condition) error { 181 | q, err := Run(condition.Value.(string)) 182 | if err != nil { 183 | return err 184 | } 185 | 186 | // If the subquery has aliases, we'll have to parse the subquery against 187 | // each file, so we don't do anything here. 188 | if len(q.SourceAliases) > 0 { 189 | condition.Subquery = q 190 | return nil 191 | } 192 | 193 | value := make(map[interface{}]bool, 0) 194 | workFunc := func(path string, info os.FileInfo, res map[string]interface{}) { 195 | for _, attr := range [...]string{"name", "size", "time", "mode"} { 196 | if q.HasAttribute(attr) { 197 | value[res[attr]] = true 198 | return 199 | } 200 | } 201 | } 202 | 203 | if err = q.Execute(workFunc); err != nil { 204 | return err 205 | } 206 | 207 | condition.Value = value 208 | condition.IsSubquery = false 209 | return nil 210 | } 211 | -------------------------------------------------------------------------------- /parser/condition_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/kashav/fsql/query" 10 | "github.com/kashav/fsql/tokenizer" 11 | ) 12 | 13 | func TestConditionParser_ExpectCorrectCondition(t *testing.T) { 14 | type Expected struct { 15 | condition *query.Condition 16 | err error 17 | } 18 | 19 | type Case struct { 20 | input string 21 | expected Expected 22 | } 23 | 24 | cases := []Case{ 25 | { 26 | input: "name LIKE foo%", 27 | expected: Expected{ 28 | condition: &query.Condition{ 29 | Attribute: "name", 30 | Operator: tokenizer.Like, 31 | Value: "foo%", 32 | }, 33 | err: nil, 34 | }, 35 | }, 36 | 37 | { 38 | input: "size = 10", 39 | expected: Expected{ 40 | condition: &query.Condition{ 41 | Attribute: "size", 42 | Operator: tokenizer.Equals, 43 | Value: "10", 44 | }, 45 | err: nil, 46 | }, 47 | }, 48 | 49 | { 50 | input: "mode IS dir", 51 | expected: Expected{ 52 | condition: &query.Condition{ 53 | Attribute: "mode", 54 | Operator: tokenizer.Is, 55 | Value: "dir", 56 | }, 57 | err: nil, 58 | }, 59 | }, 60 | 61 | { 62 | input: "format(time, iso) >= 2017-05-28T16:37:18Z", 63 | expected: Expected{ 64 | condition: &query.Condition{ 65 | Attribute: "time", 66 | AttributeModifiers: []query.Modifier{ 67 | { 68 | Name: "FORMAT", 69 | Arguments: []string{"iso"}, 70 | }, 71 | }, 72 | Operator: tokenizer.GreaterThanEquals, 73 | Value: "2017-05-28T16:37:18Z", 74 | }, 75 | err: nil, 76 | }, 77 | }, 78 | 79 | { 80 | input: "upper(name) != FOO", 81 | expected: Expected{ 82 | condition: &query.Condition{ 83 | Attribute: "name", 84 | AttributeModifiers: []query.Modifier{ 85 | { 86 | Name: "UPPER", 87 | Arguments: []string{}, 88 | }, 89 | }, 90 | Operator: tokenizer.NotEquals, 91 | Value: "FOO", 92 | }, 93 | err: nil, 94 | }, 95 | }, 96 | 97 | { 98 | input: "NOT name IN [foo,bar,baz]", 99 | expected: Expected{ 100 | condition: &query.Condition{ 101 | Attribute: "name", 102 | Operator: tokenizer.In, 103 | Value: []string{"foo", "bar", "baz"}, 104 | Negate: true, 105 | }, 106 | err: nil, 107 | }, 108 | }, 109 | 110 | // No attribute-operator validation yet (these 3 should /eventually/ throw 111 | // some error)! 112 | { 113 | input: "time RLIKE '.*'", 114 | expected: Expected{ 115 | condition: &query.Condition{ 116 | Attribute: "time", 117 | Operator: tokenizer.RLike, 118 | Value: ".*", 119 | }, 120 | err: nil, 121 | }, 122 | }, 123 | 124 | { 125 | input: "size LIKE foo", 126 | expected: Expected{ 127 | condition: &query.Condition{ 128 | Attribute: "size", 129 | Operator: tokenizer.Like, 130 | Value: "foo", 131 | }, 132 | err: nil, 133 | }, 134 | }, 135 | 136 | { 137 | input: "time <> now", 138 | expected: Expected{ 139 | condition: &query.Condition{ 140 | Attribute: "time", 141 | Operator: tokenizer.NotEquals, 142 | Value: "now", 143 | }, 144 | err: nil, 145 | }, 146 | }, 147 | 148 | { 149 | input: "name =", 150 | expected: Expected{err: io.ErrUnexpectedEOF}, 151 | }, 152 | 153 | { 154 | input: "file IS dir", 155 | expected: Expected{err: &ErrUnknownToken{"file"}}, 156 | }, 157 | } 158 | 159 | for _, c := range cases { 160 | p := &parser{tokenizer: tokenizer.NewTokenizer(c.input)} 161 | actual, err := p.parseCondition() 162 | 163 | if c.expected.err == nil { 164 | if err != nil { 165 | t.Fatalf("\nExpected no error\n Got %v", err) 166 | } 167 | if !reflect.DeepEqual(c.expected.condition, actual) { 168 | t.Fatalf("\nExpected %v\n Got %v", c.expected.condition, actual) 169 | } 170 | } else if !reflect.DeepEqual(c.expected.err, err) { 171 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 172 | } 173 | } 174 | } 175 | 176 | func TestConditionParser_ExpectCorrectConditionTree(t *testing.T) { 177 | type Expected struct { 178 | node *query.ConditionNode 179 | err error 180 | } 181 | 182 | type Case struct { 183 | input string 184 | expected Expected 185 | } 186 | 187 | // Not sure why, but compiler throws when attempting to take the memory 188 | // address of any tokenizer.TokenType. 189 | var ( 190 | tmpAnd = tokenizer.And 191 | tmpOr = tokenizer.Or 192 | ) 193 | 194 | cases := []Case{ 195 | { 196 | input: "name LIKE foo%", 197 | expected: Expected{ 198 | node: &query.ConditionNode{ 199 | Condition: &query.Condition{ 200 | Attribute: "name", 201 | Operator: tokenizer.Like, 202 | Value: "foo%", 203 | }, 204 | }, 205 | err: nil, 206 | }, 207 | }, 208 | 209 | { 210 | input: "upper(name) = MAIN", 211 | expected: Expected{ 212 | node: &query.ConditionNode{ 213 | Condition: &query.Condition{ 214 | Attribute: "name", 215 | AttributeModifiers: []query.Modifier{ 216 | { 217 | Name: "UPPER", 218 | Arguments: []string{}, 219 | }, 220 | }, 221 | Operator: tokenizer.Equals, 222 | Value: "MAIN", 223 | }, 224 | }, 225 | err: nil, 226 | }, 227 | }, 228 | 229 | { 230 | input: "name LIKE %foo AND name <> bar.foo", 231 | expected: Expected{ 232 | node: &query.ConditionNode{ 233 | Type: &tmpAnd, 234 | Left: &query.ConditionNode{ 235 | Condition: &query.Condition{ 236 | Attribute: "name", 237 | Operator: tokenizer.Like, 238 | Value: "%foo", 239 | }, 240 | }, 241 | Right: &query.ConditionNode{ 242 | Condition: &query.Condition{ 243 | Attribute: "name", 244 | Operator: tokenizer.NotEquals, 245 | Value: "bar.foo", 246 | }, 247 | }, 248 | }, 249 | err: nil, 250 | }, 251 | }, 252 | 253 | { 254 | input: "size <= 10 OR NOT mode IS dir", 255 | expected: Expected{ 256 | node: &query.ConditionNode{ 257 | Type: &tmpOr, 258 | Left: &query.ConditionNode{ 259 | Condition: &query.Condition{ 260 | Attribute: "size", 261 | Operator: tokenizer.LessThanEquals, 262 | Value: "10", 263 | }, 264 | }, 265 | Right: &query.ConditionNode{ 266 | Condition: &query.Condition{ 267 | Attribute: "mode", 268 | Operator: tokenizer.Is, 269 | Value: "dir", 270 | Negate: true, 271 | }, 272 | }, 273 | }, 274 | err: nil, 275 | }, 276 | }, 277 | 278 | { 279 | input: "size = 5 AND name = foo", 280 | expected: Expected{ 281 | node: &query.ConditionNode{ 282 | Type: &tmpAnd, 283 | Left: &query.ConditionNode{ 284 | Condition: &query.Condition{ 285 | Attribute: "size", 286 | Operator: tokenizer.Equals, 287 | Value: "5", 288 | }, 289 | }, 290 | Right: &query.ConditionNode{ 291 | Condition: &query.Condition{ 292 | Attribute: "name", 293 | Operator: tokenizer.Equals, 294 | Value: "foo", 295 | }, 296 | }, 297 | }, 298 | err: nil, 299 | }, 300 | }, 301 | 302 | { 303 | input: "format(size, mb) <= 2 AND (name = foo OR name = bar)", 304 | expected: Expected{ 305 | node: &query.ConditionNode{ 306 | Type: &tmpAnd, 307 | Left: &query.ConditionNode{ 308 | Condition: &query.Condition{ 309 | Attribute: "size", 310 | AttributeModifiers: []query.Modifier{ 311 | { 312 | Name: "FORMAT", 313 | Arguments: []string{"mb"}, 314 | }, 315 | }, 316 | Operator: tokenizer.LessThanEquals, 317 | Value: "2", 318 | }, 319 | }, 320 | Right: &query.ConditionNode{ 321 | Type: &tmpOr, 322 | Left: &query.ConditionNode{ 323 | Condition: &query.Condition{ 324 | Attribute: "name", 325 | Operator: tokenizer.Equals, 326 | Value: "foo", 327 | }, 328 | }, 329 | Right: &query.ConditionNode{ 330 | Condition: &query.Condition{ 331 | Attribute: "name", 332 | Operator: tokenizer.Equals, 333 | Value: "bar", 334 | }, 335 | }, 336 | }, 337 | }, 338 | err: nil, 339 | }, 340 | }, 341 | 342 | { 343 | input: "name = foo AND NOT (name = bar OR name = baz)", 344 | expected: Expected{ 345 | node: &query.ConditionNode{}, 346 | err: &ErrUnexpectedToken{ 347 | Expected: tokenizer.Identifier, 348 | Actual: tokenizer.OpenParen, 349 | }, 350 | }, 351 | }, 352 | 353 | { 354 | input: "size = 5 AND ()", 355 | expected: Expected{err: errors.New("failed to parse conditions")}, 356 | }, 357 | 358 | // FIXME: The following case /should/ throw EOF (it doesn't right now). 359 | // Case{input: "name = foo AND", expected: Expected{err: io.ErrUnexpectedEOF}}, 360 | } 361 | 362 | for _, c := range cases { 363 | p := &parser{tokenizer: tokenizer.NewTokenizer(c.input)} 364 | actual, err := p.parseConditionTree() 365 | 366 | if c.expected.err == nil { 367 | if err != nil { 368 | t.Fatalf("\nExpected no error\n Got %v", err) 369 | } 370 | if !reflect.DeepEqual(c.expected.node, actual) { 371 | t.Fatalf("\nExpected %v\n Got %v", c.expected.node, actual) 372 | } 373 | } else if !reflect.DeepEqual(c.expected.err, err) { 374 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 375 | } 376 | } 377 | } 378 | 379 | func TestConditionParser_ExpectCorrectSubquery(t *testing.T) { 380 | type Expected struct { 381 | condition *query.Condition 382 | err error 383 | } 384 | 385 | type Case struct { 386 | input *query.Condition 387 | expected Expected 388 | } 389 | 390 | // TODO: Complete these cases. This test relies on testdata fixtures. 391 | cases := []Case{} 392 | 393 | for _, c := range cases { 394 | p := &parser{tokenizer: tokenizer.NewTokenizer("")} 395 | err := p.parseSubquery(c.input) 396 | 397 | if c.expected.err == nil { 398 | if err != nil { 399 | t.Fatalf("\nExpected no error\n Got %v", err) 400 | } 401 | if !reflect.DeepEqual(c.expected.condition, c.input) { 402 | t.Fatalf("\nExpected %v\n Got %v", c.expected.condition, c.input) 403 | } 404 | } else if !reflect.DeepEqual(c.expected.err, err) { 405 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 406 | } 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /parser/error.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/kashav/fsql/tokenizer" 8 | ) 9 | 10 | // ErrUnexpectedToken represents an unexpected token error. 11 | type ErrUnexpectedToken struct { 12 | Actual tokenizer.TokenType 13 | Expected tokenizer.TokenType 14 | } 15 | 16 | func (e *ErrUnexpectedToken) Error() string { 17 | return fmt.Sprintf("expected %s; got %s", e.Expected.String(), 18 | e.Actual.String()) 19 | } 20 | 21 | // ErrUnknownToken represents an unknown token error. 22 | type ErrUnknownToken struct { 23 | Raw string 24 | } 25 | 26 | func (e *ErrUnknownToken) Error() string { 27 | return fmt.Sprintf("unknown token: %s", e.Raw) 28 | } 29 | 30 | // currentError returns the current error, based on the parser's current Token 31 | // and the previously expected TokenType (set in parser.expect). 32 | func (p *parser) currentError() error { 33 | if p.current == nil { 34 | return io.ErrUnexpectedEOF 35 | } 36 | 37 | if p.current.Type == tokenizer.Unknown { 38 | return &ErrUnknownToken{Raw: p.current.Raw} 39 | } 40 | 41 | return &ErrUnexpectedToken{Actual: p.current.Type, Expected: p.expected} 42 | } 43 | -------------------------------------------------------------------------------- /parser/error_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/kashav/fsql/tokenizer" 7 | ) 8 | 9 | func TestParser_ErrUnexpectedToken(t *testing.T) { 10 | err := &ErrUnexpectedToken{ 11 | Actual: tokenizer.Select, 12 | Expected: tokenizer.Where, 13 | } 14 | expected := "expected where; got select" 15 | actual := err.Error() 16 | if expected != actual { 17 | t.Fatalf("\nExpected: %s\n Got: %s", expected, actual) 18 | } 19 | } 20 | 21 | func TestParser_ErrUnknownTokent(t *testing.T) { 22 | err := &ErrUnknownToken{"r"} 23 | expected := "unknown token: r" 24 | actual := err.Error() 25 | if expected != actual { 26 | t.Fatalf("\nExpected: %s\n Got: %s", expected, actual) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /parser/parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "os/user" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/kashav/fsql/query" 9 | "github.com/kashav/fsql/tokenizer" 10 | ) 11 | 12 | // Run parses the input string and returns the parsed AST (query). 13 | func Run(input string) (*query.Query, error) { 14 | return (&parser{}).parse(input) 15 | } 16 | 17 | type parser struct { 18 | tokenizer *tokenizer.Tokenizer 19 | current *tokenizer.Token 20 | expected tokenizer.TokenType 21 | } 22 | 23 | // parse runs the respective parser function on each clause of the query. 24 | func (p *parser) parse(input string) (*query.Query, error) { 25 | q := query.NewQuery() 26 | p.tokenizer = tokenizer.NewTokenizer(input) 27 | if err := p.parseSelectClause(q); err != nil { 28 | return nil, err 29 | } 30 | if err := p.parseFromClause(q); err != nil { 31 | return nil, err 32 | } 33 | if err := p.parseWhereClause(q); err != nil { 34 | return nil, err 35 | } 36 | return q, nil 37 | } 38 | 39 | // parseSelectClause parses the SELECT clause of the query. 40 | func (p *parser) parseSelectClause(q *query.Query) error { 41 | // Determine if we should show all attributes. This is only true when 42 | // no attributes are provided (regardless of if the SELECT keyword is 43 | // provided or not). 44 | var showAll = true 45 | if p.expect(tokenizer.Select) == nil { 46 | if p.current == nil || p.current.Type == tokenizer.Identifier { 47 | showAll = false 48 | } else if p.current.Type == tokenizer.From || p.current.Type == tokenizer.Where { 49 | // No SELECT and next token is FROM/WHERE, show all! 50 | showAll = true 51 | } else { 52 | // No SELECT and next token is not Identifier nor FROM/WHERE -> malformed 53 | // input. 54 | return p.currentError() 55 | } 56 | } else if current := p.expect(tokenizer.Identifier); current != nil { 57 | p.current = current 58 | showAll = false 59 | } 60 | 61 | if showAll { 62 | q.Attributes = allAttributes 63 | } else if err := p.parseAttrs(&q.Attributes, &q.Modifiers); err != nil { 64 | return err 65 | } 66 | 67 | return nil 68 | } 69 | 70 | // parseFromClause parses the FROM clause of the query. 71 | func (p *parser) parseFromClause(q *query.Query) error { 72 | if p.expect(tokenizer.From) == nil { 73 | err := p.currentError() 74 | if p.expect(tokenizer.Identifier) != nil { 75 | // No FROM, but an identifier -> malformed query. 76 | return err 77 | } 78 | 79 | // No specified directory, so we default to the CWD. 80 | q.Sources["include"] = append(q.Sources["include"], ".") 81 | return nil 82 | } 83 | 84 | if err := p.parseSourceList(&q.Sources, &q.SourceAliases); err != nil { 85 | return err 86 | } 87 | 88 | // Replace the tilde with the home directory in each source directory. This 89 | // is only required when the query is wrapped in quotes, since the shell 90 | // will automatically expand tildes otherwise. 91 | u, err := user.Current() 92 | if err != nil { 93 | return err 94 | } 95 | for _, sourceType := range []string{"include", "exclude"} { 96 | for i, src := range q.Sources[sourceType] { 97 | if strings.Contains(src, "~") { 98 | q.Sources[sourceType][i] = filepath.Join(u.HomeDir, src[1:]) 99 | } 100 | } 101 | } 102 | 103 | return nil 104 | } 105 | 106 | // parseWhereClause parses the WHERE clause of the query. 107 | func (p *parser) parseWhereClause(q *query.Query) error { 108 | if p.expect(tokenizer.Where) == nil { 109 | err := p.currentError() 110 | if p.expect(tokenizer.Identifier) == nil { 111 | return nil 112 | } 113 | return err 114 | } 115 | root, err := p.parseConditionTree() 116 | if err != nil { 117 | return err 118 | } 119 | q.ConditionTree = root 120 | 121 | return nil 122 | } 123 | 124 | // expect returns the next token if it matches the expectation t, and 125 | // nil otherwise. 126 | func (p *parser) expect(t tokenizer.TokenType) *tokenizer.Token { 127 | p.expected = t 128 | 129 | if p.current == nil { 130 | p.current = p.tokenizer.Next() 131 | } 132 | 133 | if p.current != nil && p.current.Type == t { 134 | tok := p.current 135 | p.current = nil 136 | return tok 137 | } 138 | 139 | return nil 140 | } 141 | -------------------------------------------------------------------------------- /parser/parser_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "io" 5 | "os/user" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/kashav/fsql/query" 10 | "github.com/kashav/fsql/tokenizer" 11 | ) 12 | 13 | func TestParser_ParseSelect(t *testing.T) { 14 | type Expected struct { 15 | attributes []string 16 | modifiers map[string][]query.Modifier 17 | err error 18 | } 19 | 20 | type Case struct { 21 | input string 22 | expected Expected 23 | } 24 | 25 | cases := []Case{ 26 | { 27 | input: "all", 28 | expected: Expected{ 29 | attributes: allAttributes, 30 | modifiers: map[string][]query.Modifier{}, 31 | err: nil, 32 | }, 33 | }, 34 | 35 | { 36 | input: "SELECT", 37 | expected: Expected{ 38 | attributes: allAttributes, 39 | modifiers: map[string][]query.Modifier{}, 40 | err: nil, 41 | }, 42 | }, 43 | 44 | { 45 | input: "FROM", 46 | expected: Expected{ 47 | attributes: allAttributes, 48 | modifiers: map[string][]query.Modifier{}, 49 | err: nil, 50 | }, 51 | }, 52 | 53 | { 54 | input: "SELECT name", 55 | expected: Expected{ 56 | attributes: []string{"name"}, 57 | modifiers: map[string][]query.Modifier{"name": {}}, 58 | err: nil, 59 | }, 60 | }, 61 | 62 | { 63 | input: "SELECT format(size, kb)", 64 | expected: Expected{ 65 | attributes: []string{"size"}, 66 | modifiers: map[string][]query.Modifier{ 67 | "size": { 68 | { 69 | Name: "FORMAT", 70 | Arguments: []string{"kb"}, 71 | }, 72 | }, 73 | }, 74 | err: nil, 75 | }, 76 | }, 77 | 78 | { 79 | input: "", 80 | expected: Expected{err: io.ErrUnexpectedEOF}, 81 | }, 82 | } 83 | 84 | for _, c := range cases { 85 | q := query.NewQuery() 86 | err := (&parser{tokenizer: tokenizer.NewTokenizer(c.input)}).parseSelectClause(q) 87 | 88 | if c.expected.err == nil { 89 | if err != nil { 90 | t.Fatalf("\nExpected no error\n Got %v", err) 91 | } 92 | if !reflect.DeepEqual(c.expected.attributes, q.Attributes) { 93 | t.Fatalf("\nExpected %v\n Got %v", c.expected.attributes, q.Attributes) 94 | } 95 | if !reflect.DeepEqual(c.expected.modifiers, q.Modifiers) { 96 | t.Fatalf("\nExpected %v\n Got %v", c.expected.modifiers, q.Modifiers) 97 | } 98 | } else if !reflect.DeepEqual(c.expected.err, err) { 99 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 100 | } 101 | } 102 | } 103 | 104 | func TestParser_ParseFrom(t *testing.T) { 105 | type Expected struct { 106 | sources map[string][]string 107 | aliases map[string]string 108 | err error 109 | } 110 | 111 | type Case struct { 112 | input string 113 | expected Expected 114 | } 115 | 116 | u, err := user.Current() 117 | if err != nil { 118 | // TODO: If we can't get the current user, should we fatal or just return? 119 | return 120 | } 121 | 122 | cases := []Case{ 123 | { 124 | input: "WHERE", 125 | expected: Expected{ 126 | sources: map[string][]string{ 127 | "include": {"."}, 128 | "exclude": {}, 129 | }, 130 | aliases: map[string]string{}, 131 | err: nil, 132 | }, 133 | }, 134 | 135 | { 136 | input: "FROM .", 137 | expected: Expected{ 138 | sources: map[string][]string{ 139 | "include": {"."}, 140 | "exclude": {}, 141 | }, 142 | aliases: map[string]string{}, 143 | err: nil, 144 | }, 145 | }, 146 | 147 | { 148 | input: "FROM ~/foo, -./.git/", 149 | expected: Expected{ 150 | sources: map[string][]string{ 151 | "include": {u.HomeDir + "/foo"}, 152 | "exclude": {".git"}, 153 | }, 154 | aliases: map[string]string{}, 155 | err: nil, 156 | }, 157 | }, 158 | 159 | { 160 | input: "FROM ./foo/ AS foo", 161 | expected: Expected{ 162 | sources: map[string][]string{ 163 | "include": {"foo"}, 164 | "exclude": {}, 165 | }, 166 | aliases: map[string]string{"foo": "foo"}, 167 | err: nil, 168 | }, 169 | }, 170 | 171 | { 172 | input: "FROM", 173 | expected: Expected{err: io.ErrUnexpectedEOF}, 174 | }, 175 | 176 | { 177 | input: "FROM WHERE", 178 | expected: Expected{ 179 | err: &ErrUnexpectedToken{ 180 | Actual: tokenizer.Where, 181 | Expected: tokenizer.Identifier, 182 | }, 183 | }, 184 | }, 185 | } 186 | 187 | for _, c := range cases { 188 | q := query.NewQuery() 189 | err := (&parser{tokenizer: tokenizer.NewTokenizer(c.input)}).parseFromClause(q) 190 | 191 | if c.expected.err == nil { 192 | if err != nil { 193 | t.Fatalf("\nExpected no error\n Got %v", err) 194 | } 195 | if !reflect.DeepEqual(c.expected.sources, q.Sources) { 196 | t.Fatalf("\nExpected %v\n Got %v", c.expected.sources, q.Sources) 197 | } 198 | if !reflect.DeepEqual(c.expected.aliases, q.SourceAliases) { 199 | t.Fatalf("\nExpected %v\n Got %v", c.expected.aliases, q.SourceAliases) 200 | } 201 | } else if !reflect.DeepEqual(c.expected.err, err) { 202 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 203 | } 204 | } 205 | } 206 | 207 | func TestParser_ParseWhere(t *testing.T) { 208 | type Expected struct { 209 | tree *query.ConditionNode 210 | err error 211 | } 212 | 213 | type Case struct { 214 | input string 215 | expected Expected 216 | } 217 | 218 | cases := []Case{ 219 | { 220 | input: "WHERE name LIKE foo", 221 | expected: Expected{ 222 | tree: &query.ConditionNode{ 223 | Condition: &query.Condition{ 224 | Attribute: "name", 225 | Operator: tokenizer.Like, 226 | Value: "foo", 227 | }, 228 | }, 229 | err: nil, 230 | }, 231 | }, 232 | 233 | // Our tree is fully-zeroed in this case, so it's easier just to give it 234 | // an empty Expected struct. 235 | {input: "", expected: Expected{}}, 236 | 237 | {input: "WHERE", expected: Expected{err: io.ErrUnexpectedEOF}}, 238 | 239 | { 240 | input: "name LIKE foo", 241 | expected: Expected{ 242 | err: &ErrUnexpectedToken{ 243 | Expected: tokenizer.Where, 244 | Actual: tokenizer.Identifier, 245 | }, 246 | }, 247 | }, 248 | } 249 | 250 | for _, c := range cases { 251 | q := query.NewQuery() 252 | err := (&parser{tokenizer: tokenizer.NewTokenizer(c.input)}).parseWhereClause(q) 253 | 254 | if c.expected.err == nil { 255 | if err != nil { 256 | t.Fatalf("\nExpected no error\n Got %v", err) 257 | } 258 | if !reflect.DeepEqual(c.expected.tree, q.ConditionTree) { 259 | t.Fatalf("\nExpected %v\n Got %v", c.expected.tree, q.ConditionTree) 260 | } 261 | } else if !reflect.DeepEqual(c.expected.err, err) { 262 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 263 | } 264 | } 265 | } 266 | 267 | func TestParser_Expect(t *testing.T) { 268 | type Case struct { 269 | param tokenizer.TokenType 270 | expected *tokenizer.Token 271 | } 272 | 273 | input := "SELECT all FROM . WHERE name = foo OR size <> 100" 274 | p := &parser{tokenizer: tokenizer.NewTokenizer(input)} 275 | 276 | cases := []Case{ 277 | { 278 | param: tokenizer.Select, 279 | expected: &tokenizer.Token{Type: tokenizer.Select, Raw: "SELECT"}, 280 | }, 281 | { 282 | param: tokenizer.From, 283 | expected: nil, 284 | }, 285 | { 286 | param: tokenizer.Identifier, 287 | expected: &tokenizer.Token{Type: tokenizer.Identifier, Raw: "all"}, 288 | }, 289 | { 290 | param: tokenizer.Identifier, 291 | expected: nil, 292 | }, 293 | { 294 | param: tokenizer.From, 295 | expected: &tokenizer.Token{Type: tokenizer.From, Raw: "FROM"}, 296 | }, 297 | { 298 | param: tokenizer.Identifier, 299 | expected: &tokenizer.Token{Type: tokenizer.Identifier, Raw: "."}, 300 | }, 301 | { 302 | param: tokenizer.Identifier, 303 | expected: nil, 304 | }, 305 | { 306 | param: tokenizer.Where, 307 | expected: &tokenizer.Token{Type: tokenizer.Where, Raw: "WHERE"}, 308 | }, 309 | { 310 | param: tokenizer.Identifier, 311 | expected: &tokenizer.Token{Type: tokenizer.Identifier, Raw: "name"}, 312 | }, 313 | { 314 | param: tokenizer.Equals, 315 | expected: &tokenizer.Token{Type: tokenizer.Equals, Raw: "="}, 316 | }, 317 | { 318 | param: tokenizer.Identifier, 319 | expected: &tokenizer.Token{Type: tokenizer.Identifier, Raw: "foo"}, 320 | }, 321 | { 322 | param: tokenizer.Or, 323 | expected: &tokenizer.Token{Type: tokenizer.Or, Raw: "OR"}, 324 | }, 325 | { 326 | param: tokenizer.Identifier, 327 | expected: &tokenizer.Token{Type: tokenizer.Identifier, Raw: "size"}, 328 | }, 329 | { 330 | param: tokenizer.Identifier, 331 | expected: nil, 332 | }, 333 | { 334 | param: tokenizer.NotEquals, 335 | expected: &tokenizer.Token{Type: tokenizer.NotEquals, Raw: "<>"}, 336 | }, 337 | { 338 | param: tokenizer.Identifier, 339 | expected: &tokenizer.Token{Type: tokenizer.Identifier, Raw: "100"}, 340 | }, 341 | } 342 | 343 | for _, c := range cases { 344 | actual := p.expect(c.param) 345 | if !reflect.DeepEqual(c.expected, actual) { 346 | t.Fatalf("\nExpected %v\n Got %v", c.expected, actual) 347 | } 348 | } 349 | } 350 | 351 | func TestParser_SelectAllVariations(t *testing.T) { 352 | expected := &query.Query{ 353 | Attributes: allAttributes, 354 | Sources: map[string][]string{ 355 | "include": {"."}, 356 | "exclude": {}, 357 | }, 358 | ConditionTree: &query.ConditionNode{ 359 | Condition: &query.Condition{ 360 | Attribute: "name", 361 | Operator: tokenizer.Like, 362 | Value: "foo", 363 | }, 364 | }, 365 | SourceAliases: map[string]string{}, 366 | Modifiers: map[string][]query.Modifier{}, 367 | } 368 | 369 | cases := []string{ 370 | "FROM . WHERE name LIKE foo", 371 | "all FROM . WHERE name LIKE foo", 372 | "SELECT FROM . WHERE name LIKE foo", 373 | "SELECT all FROM . WHERE name LIKE foo", 374 | } 375 | 376 | for _, c := range cases { 377 | actual, err := Run(c) 378 | if err != nil { 379 | t.Fatalf("\nExpected no error\n Got %v", err) 380 | } 381 | if !reflect.DeepEqual(expected, actual) { 382 | t.Fatalf("\nExpected %v\n Got %v", expected, actual) 383 | } 384 | } 385 | } 386 | 387 | func TestParser_Run(t *testing.T) { 388 | type Expected struct { 389 | q *query.Query 390 | err error 391 | } 392 | 393 | type Case struct { 394 | input string 395 | expected Expected 396 | } 397 | 398 | // TODO: Add more cases. 399 | cases := []Case{ 400 | { 401 | input: "SELECT all FROM . WHERE name LIKE foo", 402 | expected: Expected{ 403 | q: &query.Query{ 404 | Attributes: allAttributes, 405 | Sources: map[string][]string{ 406 | "include": {"."}, 407 | "exclude": {}, 408 | }, 409 | ConditionTree: &query.ConditionNode{ 410 | Condition: &query.Condition{ 411 | Attribute: "name", 412 | Operator: tokenizer.Like, 413 | Value: "foo", 414 | }, 415 | }, 416 | SourceAliases: map[string]string{}, 417 | Modifiers: map[string][]query.Modifier{}, 418 | }, 419 | err: nil, 420 | }, 421 | }, 422 | } 423 | 424 | for _, c := range cases { 425 | actual, err := Run(c.input) 426 | if c.expected.err == nil { 427 | if err != nil { 428 | t.Fatalf("\nExpected no error\n Got %v", err) 429 | } 430 | if !reflect.DeepEqual(c.expected.q, actual) { 431 | t.Fatalf("\nExpected %v\n Got %v", c.expected.q, actual) 432 | } 433 | } else if !reflect.DeepEqual(c.expected.err, err) { 434 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 435 | } 436 | } 437 | } 438 | -------------------------------------------------------------------------------- /parser/source.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "github.com/kashav/fsql/tokenizer" 8 | ) 9 | 10 | // parseSourceList parses the list of directories passed to the FROM clause. If 11 | // a source is followed by the AS keyword, the following word is registered as 12 | // an alias. 13 | func (p *parser) parseSourceList(sources *map[string][]string, 14 | aliases *map[string]string) error { 15 | for { 16 | // If the next token is a hypen, exclude this directory. 17 | sourceType := "include" 18 | if token := p.expect(tokenizer.Hyphen); token != nil { 19 | sourceType = "exclude" 20 | } 21 | 22 | source := p.expect(tokenizer.Identifier) 23 | if source == nil { 24 | return p.currentError() 25 | } 26 | source.Raw = filepath.Clean(source.Raw) 27 | (*sources)[sourceType] = append((*sources)[sourceType], source.Raw) 28 | 29 | if token := p.expect(tokenizer.As); token != nil { 30 | alias := p.expect(tokenizer.Identifier) 31 | if alias == nil { 32 | return p.currentError() 33 | } 34 | if sourceType == "exclude" { 35 | return fmt.Errorf("cannot alias excluded directory %s", source.Raw) 36 | } 37 | (*aliases)[alias.Raw] = source.Raw 38 | } 39 | 40 | if p.expect(tokenizer.Comma) == nil { 41 | break 42 | } 43 | } 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /parser/source_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/kashav/fsql/tokenizer" 10 | ) 11 | 12 | func TestSourceParser_ExpectCorrectSources(t *testing.T) { 13 | type Expected struct { 14 | sources map[string][]string 15 | err error 16 | } 17 | 18 | type Case struct { 19 | input string 20 | expected Expected 21 | } 22 | 23 | cases := []Case{ 24 | { 25 | input: ".", 26 | expected: Expected{ 27 | sources: map[string][]string{"include": {"."}}, 28 | err: nil, 29 | }, 30 | }, 31 | { 32 | input: "., ~/foo", 33 | expected: Expected{ 34 | sources: map[string][]string{"include": {".", "~/foo"}}, 35 | err: nil, 36 | }, 37 | }, 38 | { 39 | input: "., -.bar", 40 | expected: Expected{ 41 | sources: map[string][]string{ 42 | "include": {"."}, 43 | "exclude": {".bar"}, 44 | }, 45 | err: nil, 46 | }, 47 | }, 48 | { 49 | input: "-.bar, ., ~/foo AS foo", 50 | expected: Expected{ 51 | sources: map[string][]string{ 52 | "include": {".", "~/foo"}, 53 | "exclude": {".bar"}, 54 | }, 55 | err: nil, 56 | }, 57 | }, 58 | 59 | {input: "", expected: Expected{err: io.ErrUnexpectedEOF}}, 60 | {input: "foo,", expected: Expected{err: io.ErrUnexpectedEOF}}, 61 | } 62 | 63 | for _, c := range cases { 64 | sources := make(map[string][]string, 0) 65 | aliases := make(map[string]string, 0) 66 | 67 | p := &parser{tokenizer: tokenizer.NewTokenizer(c.input)} 68 | err := p.parseSourceList(&sources, &aliases) 69 | 70 | if c.expected.err == nil { 71 | if err != nil { 72 | t.Fatalf("\nExpected no error\n Got %v", err) 73 | } 74 | if !reflect.DeepEqual(c.expected.sources, sources) { 75 | t.Fatalf("\nExpected %v\n Got %v", c.expected.sources, sources) 76 | } 77 | } else if !reflect.DeepEqual(c.expected.err, err) { 78 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 79 | } 80 | } 81 | } 82 | 83 | func TestSourceParser_ExpectCorrectAliases(t *testing.T) { 84 | type Expected struct { 85 | aliases map[string]string 86 | err error 87 | } 88 | 89 | type Case struct { 90 | input string 91 | expected Expected 92 | } 93 | 94 | cases := []Case{ 95 | { 96 | input: ".", 97 | expected: Expected{ 98 | aliases: map[string]string{}, 99 | err: nil, 100 | }, 101 | }, 102 | { 103 | input: ". AS cwd", 104 | expected: Expected{ 105 | aliases: map[string]string{"cwd": "."}, 106 | err: nil, 107 | }, 108 | }, 109 | { 110 | input: "., -.bar, ~/foo AS foo", 111 | expected: Expected{ 112 | aliases: map[string]string{"foo": "~/foo"}, 113 | err: nil, 114 | }, 115 | }, 116 | 117 | { 118 | input: "-.bar AS bar", 119 | expected: Expected{err: errors.New("cannot alias excluded directory .bar")}, 120 | }, 121 | { 122 | input: "", 123 | expected: Expected{err: io.ErrUnexpectedEOF}, 124 | }, 125 | { 126 | input: "foo AS", 127 | expected: Expected{err: io.ErrUnexpectedEOF}, 128 | }, 129 | } 130 | 131 | for _, c := range cases { 132 | sources := make(map[string][]string, 0) 133 | aliases := make(map[string]string, 0) 134 | 135 | p := &parser{tokenizer: tokenizer.NewTokenizer(c.input)} 136 | err := p.parseSourceList(&sources, &aliases) 137 | 138 | if c.expected.err == nil { 139 | if err != nil { 140 | t.Fatalf("\nExpected no error\n Got %v", err) 141 | } 142 | if !reflect.DeepEqual(c.expected.aliases, aliases) { 143 | t.Fatalf("\nExpected %v\n Got %v", c.expected.aliases, aliases) 144 | } 145 | } else if !reflect.DeepEqual(c.expected.err, err) { 146 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /query/condition.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/kashav/fsql/evaluate" 9 | "github.com/kashav/fsql/tokenizer" 10 | "github.com/kashav/fsql/transform" 11 | ) 12 | 13 | // ConditionNode represents a single node of a query's WHERE clause tree. 14 | type ConditionNode struct { 15 | Type *tokenizer.TokenType 16 | Left *ConditionNode 17 | Right *ConditionNode 18 | Condition *Condition 19 | } 20 | 21 | func (root *ConditionNode) String() string { 22 | if root == nil { 23 | return "" 24 | } 25 | 26 | return fmt.Sprintf("{%v (%v %v) %v}", root.Type, root.Left, root.Right, 27 | root.Condition) 28 | } 29 | 30 | // evaluateTree runs pre-order traversal on the ConditionNode tree rooted at 31 | // root and evaluates each conditional along the path with the provided compare 32 | // method. 33 | func (root *ConditionNode) evaluateTree(path string, info os.FileInfo) (bool, error) { 34 | if root == nil { 35 | return true, nil 36 | } 37 | 38 | if root.Condition != nil { 39 | if root.Condition.IsSubquery { 40 | // Unevaluated subquery. 41 | // TODO: Handle this case. 42 | return false, errors.New("not implemented") 43 | } 44 | 45 | if !root.Condition.Parsed { 46 | if err := root.Condition.applyModifiers(); err != nil { 47 | return false, err 48 | } 49 | } 50 | 51 | return root.Condition.evaluate(path, info) 52 | } 53 | 54 | if *root.Type == tokenizer.And { 55 | if ok, err := root.Left.evaluateTree(path, info); err != nil { 56 | return false, err 57 | } else if !ok { 58 | return false, nil 59 | } 60 | return root.Right.evaluateTree(path, info) 61 | } 62 | 63 | if *root.Type == tokenizer.Or { 64 | if ok, err := root.Left.evaluateTree(path, info); err != nil { 65 | return false, nil 66 | } else if ok { 67 | return true, nil 68 | } 69 | return root.Right.evaluateTree(path, info) 70 | } 71 | 72 | return false, nil 73 | } 74 | 75 | // Condition represents a WHERE condition. 76 | type Condition struct { 77 | Attribute string 78 | AttributeModifiers []Modifier 79 | Parsed bool 80 | 81 | Operator tokenizer.TokenType 82 | Value interface{} 83 | Negate bool 84 | 85 | Subquery *Query 86 | IsSubquery bool 87 | } 88 | 89 | // ApplyModifiers applies each modifier to the value of this Condition. 90 | func (c *Condition) applyModifiers() error { 91 | value := c.Value 92 | 93 | for _, m := range c.AttributeModifiers { 94 | var err error 95 | value, err = transform.Parse(&transform.ParseParams{ 96 | Attribute: c.Attribute, 97 | Value: value, 98 | Name: m.Name, 99 | Args: m.Arguments, 100 | }) 101 | if err != nil { 102 | return err 103 | } 104 | } 105 | 106 | c.Value = value 107 | c.Parsed = true 108 | return nil 109 | } 110 | 111 | // evaluate runs the respective evaluate function for this Condition. 112 | func (c *Condition) evaluate(path string, file os.FileInfo) (bool, error) { 113 | // FIXME: This is a bit of a hack. We can't pass c.AttributeModifiers, since 114 | // that'll cause a import cycle, so we have to recreate the attribute 115 | // modifiers slice using a separate type defined in evaluate. 116 | modifiers := make([]evaluate.Modifier, len(c.AttributeModifiers)) 117 | for i, m := range c.AttributeModifiers { 118 | modifiers[i] = evaluate.Modifier{Name: m.Name, Arguments: m.Arguments} 119 | } 120 | 121 | o := &evaluate.Opts{ 122 | Path: path, 123 | File: file, 124 | Attribute: c.Attribute, 125 | Modifiers: modifiers, 126 | Operator: c.Operator, 127 | Value: c.Value, 128 | } 129 | result, err := evaluate.Evaluate(o) 130 | if err != nil { 131 | return false, err 132 | } 133 | if c.Negate { 134 | return !result, nil 135 | } 136 | return result, nil 137 | } 138 | -------------------------------------------------------------------------------- /query/condition_test.go: -------------------------------------------------------------------------------- 1 | package query 2 | -------------------------------------------------------------------------------- /query/excluder.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | // Excluder allows us to support different methods of excluding in the future. 10 | type Excluder interface { 11 | shouldExclude(path string) bool 12 | } 13 | 14 | // regexpExclude uses regular expressions to tell if a file/path should be 15 | // excluded. 16 | type regexpExclude struct { 17 | exclusions []string 18 | regex *regexp.Regexp 19 | } 20 | 21 | // ShouldExclude will return a boolean denoting whether or not the path should 22 | // be excluded based on the given slice of exclusions. 23 | func (r *regexpExclude) shouldExclude(path string) bool { 24 | if r.regex == nil { 25 | r.buildRegex() 26 | } 27 | if r.regex.String() == "" { 28 | return false 29 | } 30 | return r.regex.MatchString(path) 31 | } 32 | 33 | // buildRegex builds the regular expression for this RegexpExclude. 34 | func (r *regexpExclude) buildRegex() { 35 | exclusions := make([]string, len(r.exclusions)) 36 | for i, exclusion := range r.exclusions { 37 | // Wrap exclusion in ^ and (/.*)?$ AFTER trimming trailing slashes and 38 | // escaping all dots. 39 | exclusions[i] = fmt.Sprintf("^%s(/.*)?$", 40 | strings.Replace(strings.TrimRight(exclusion, "/"), ".", "\\.", -1)) 41 | } 42 | r.regex = regexp.MustCompile(strings.Join(exclusions, "|")) 43 | } 44 | -------------------------------------------------------------------------------- /query/excluder_test.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import "testing" 4 | 5 | func TestShouldExclude_ExpectAllExcluded(t *testing.T) { 6 | type Case struct { 7 | input string 8 | expected bool 9 | } 10 | 11 | exclusions := []string{".git", ".gitignore"} 12 | excluder := regexpExclude{exclusions: exclusions} 13 | 14 | cases := []Case{ 15 | {input: ".git", expected: true}, 16 | {input: ".git/", expected: true}, 17 | {input: ".git/some/other/file", expected: true}, 18 | {input: ".gitignore", expected: true}, 19 | } 20 | 21 | for _, c := range cases { 22 | actual := excluder.shouldExclude(c.input) 23 | if actual != c.expected { 24 | t.Fatalf("\nExpected %v\n Got %v", c.expected, actual) 25 | } 26 | } 27 | } 28 | 29 | func TestShouldExclude_ExpectNotExcluded(t *testing.T) { 30 | type Case struct { 31 | input string 32 | expected bool 33 | } 34 | 35 | exclusions := []string{".git"} 36 | excluder := regexpExclude{exclusions: exclusions} 37 | 38 | cases := []Case{ 39 | {input: ".git", expected: true}, 40 | {input: ".git/", expected: true}, 41 | {input: ".git/some/other/file", expected: true}, 42 | {input: ".gitignore", expected: false}, 43 | } 44 | 45 | for _, c := range cases { 46 | actual := excluder.shouldExclude(c.input) 47 | if actual != c.expected { 48 | t.Fatalf("\nExpected %v\n Got %v", c.expected, actual) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /query/modifier.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/kashav/fsql/transform" 9 | ) 10 | 11 | // Modifier represents an attribute modifier. 12 | type Modifier struct { 13 | Name string 14 | Arguments []string 15 | } 16 | 17 | func (m *Modifier) String() string { 18 | return fmt.Sprintf("%s(%s)", m.Name, strings.Join(m.Arguments, ", ")) 19 | } 20 | 21 | // applyModifiers iterates through each SELECT attribute for this query 22 | // and applies the associated modifier to the attribute's output value. 23 | func (q *Query) applyModifiers(path string, info os.FileInfo) (map[string]interface{}, error) { 24 | results := make(map[string]interface{}, len(q.Attributes)) 25 | 26 | for _, attribute := range q.Attributes { 27 | value, err := transform.DefaultFormatValue(attribute, path, info) 28 | if err != nil { 29 | return map[string]interface{}{}, err 30 | } 31 | 32 | if _, ok := q.Modifiers[attribute]; !ok { 33 | results[attribute] = value 34 | continue 35 | } 36 | 37 | for _, m := range q.Modifiers[attribute] { 38 | value, err = transform.Format(&transform.FormatParams{ 39 | Attribute: attribute, 40 | Path: path, 41 | Info: info, 42 | Value: value, 43 | Name: m.Name, 44 | Args: m.Arguments, 45 | }) 46 | if err != nil { 47 | return map[string]interface{}{}, err 48 | } 49 | } 50 | 51 | results[attribute] = value 52 | } 53 | 54 | return results, nil 55 | } 56 | -------------------------------------------------------------------------------- /query/modifier_test.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import "testing" 4 | 5 | func TestModifier_String(t *testing.T) { 6 | type Case struct { 7 | input Modifier 8 | expected string 9 | } 10 | 11 | cases := []Case{ 12 | { 13 | input: Modifier{Name: "upper", Arguments: []string{}}, 14 | expected: "upper()", 15 | }, 16 | { 17 | input: Modifier{Name: "format", Arguments: []string{"upper"}}, 18 | expected: "format(upper)", 19 | }, 20 | } 21 | 22 | for _, c := range cases { 23 | result := c.input.String() 24 | if result != c.expected { 25 | t.Fatalf("\nExpected: %s\n Got: %s", c.expected, result) 26 | } 27 | } 28 | } 29 | 30 | func TestModifier_Apply(t *testing.T) { 31 | // TODO 32 | } 33 | -------------------------------------------------------------------------------- /query/query.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | // Query represents an input query. 10 | type Query struct { 11 | Attributes []string 12 | Modifiers map[string][]Modifier 13 | 14 | Sources map[string][]string 15 | SourceAliases map[string]string 16 | 17 | ConditionTree *ConditionNode 18 | } 19 | 20 | // NewQuery returns a pointer to a Query. 21 | func NewQuery() *Query { 22 | return &Query{ 23 | Attributes: make([]string, 0), 24 | Modifiers: make(map[string][]Modifier), 25 | Sources: map[string][]string{ 26 | "include": make([]string, 0), 27 | "exclude": make([]string, 0), 28 | }, 29 | SourceAliases: make(map[string]string), 30 | ConditionTree: nil, 31 | } 32 | } 33 | 34 | // HasAttribute checks if this query contains any of the provided attributes. 35 | func (q *Query) HasAttribute(attributes ...string) bool { 36 | for _, attribute := range attributes { 37 | for _, queryAttribute := range q.Attributes { 38 | if attribute == queryAttribute { 39 | return true 40 | } 41 | } 42 | } 43 | return false 44 | } 45 | 46 | // Execute runs the query by walking the full path of each source and 47 | // evaluating the condition tree for each file. This method calls workFunc on 48 | // each "successful" file. 49 | func (q *Query) Execute(workFunc interface{}) error { 50 | seen := map[string]bool{} 51 | excluder := ®expExclude{exclusions: q.Sources["exclude"]} 52 | 53 | for _, src := range q.Sources["include"] { 54 | // TODO: Improve our method of detecting if src is a glob pattern. This 55 | // currently doesn't support usage of square brackets, since the tokenizer 56 | // doesn't recognize these as part of a directory. 57 | // 58 | // Pattern reference: https://golang.org/pkg/path/filepath/#Match. 59 | if strings.ContainsAny(src, "*?") { 60 | // If src does _resemble_ a glob pattern, we find all matches and 61 | // evaluate the condition tree against each. 62 | matches, err := filepath.Glob(src) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | for _, match := range matches { 68 | if err = filepath.Walk(match, q.walkFunc(seen, excluder, workFunc)); err != nil { 69 | return err 70 | } 71 | } 72 | continue 73 | } 74 | 75 | if err := filepath.Walk(src, q.walkFunc(seen, excluder, workFunc)); err != nil { 76 | return err 77 | } 78 | } 79 | 80 | return nil 81 | } 82 | 83 | // walkFunc returns a filepath.WalkFunc which evaluates the condition tree 84 | // against the given file. 85 | func (q *Query) walkFunc(seen map[string]bool, excluder Excluder, 86 | workFunc interface{}) filepath.WalkFunc { 87 | return func(path string, info os.FileInfo, err error) error { 88 | if err != nil { 89 | return err 90 | } 91 | 92 | if path == "." { 93 | return nil 94 | } 95 | 96 | // Avoid walking a single directory more than once. 97 | if _, ok := seen[path]; ok { 98 | return nil 99 | } 100 | seen[path] = true 101 | 102 | if excluder.shouldExclude(path) { 103 | return nil 104 | } 105 | 106 | if ok, err := q.ConditionTree.evaluateTree(path, info); err != nil { 107 | return err 108 | } else if !ok { 109 | return nil 110 | } 111 | 112 | results, err := q.applyModifiers(path, info) 113 | if err != nil { 114 | return err 115 | } 116 | workFunc.(func(string, os.FileInfo, map[string]interface{}))(path, info, results) 117 | return nil 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /query/query_test.go: -------------------------------------------------------------------------------- 1 | package query 2 | -------------------------------------------------------------------------------- /terminal/pager/pager.go: -------------------------------------------------------------------------------- 1 | package pager 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "os/exec" 7 | ) 8 | 9 | const cmd = "less" 10 | 11 | var opts = []string{"-S"} 12 | 13 | // CommandExists returns true iff cmd exists on the host machine. 14 | func CommandExists() bool { 15 | _, err := exec.LookPath(cmd) 16 | return err == nil 17 | } 18 | 19 | // New invokes cmd with in provided as stdin, if cmd is unavailable, return 20 | // an error. 21 | func New(in []byte) error { 22 | path, err := exec.LookPath(cmd) 23 | if err != nil { 24 | return err 25 | } 26 | pager := exec.Command(path, opts...) 27 | pager.Stdin = bytes.NewReader(in) 28 | pager.Stdout = os.Stdout 29 | pager.Stderr = os.Stderr 30 | return pager.Run() 31 | } 32 | -------------------------------------------------------------------------------- /terminal/terminal.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | 11 | "github.com/kashav/fsql" 12 | "github.com/kashav/fsql/terminal/pager" 13 | 14 | "golang.org/x/crypto/ssh/terminal" 15 | ) 16 | 17 | var fd = int(os.Stdin.Fd()) 18 | var query bytes.Buffer 19 | 20 | // Start listens for queries via stdin and invokes fsql.Run whenever a 21 | // semicolon is read. 22 | func Start() error { 23 | if !terminal.IsTerminal(fd) { 24 | return errors.New("not a terminal") 25 | } 26 | 27 | state, err := terminal.MakeRaw(fd) 28 | if err != nil { 29 | return err 30 | } 31 | defer terminal.Restore(fd, state) 32 | 33 | prompt := ">>> " 34 | term := terminal.NewTerminal(os.Stdin, prompt) 35 | 36 | // Listen for queries and invoke run whenever a semicolon is read. Continues 37 | // until receiving an EOF (Ctrl-D) or _fatal_ error (i.e. anything not 38 | // caused by the query itself). 39 | for { 40 | line, err := term.ReadLine() 41 | if err == io.EOF { 42 | fmt.Print("\r\nbye\r\n") 43 | break 44 | } 45 | if err != nil { 46 | return err 47 | } 48 | 49 | if line == "exit" { 50 | fmt.Print("bye\r\n") 51 | break 52 | } 53 | 54 | // TODO: If the previous character was a paren., bracket, or quote, we 55 | // don't want to add a space here (although not necessary, since the 56 | // tokenizer handles excess whitespace). 57 | if query.Len() > 0 { 58 | query.WriteString(" ") 59 | } 60 | query.WriteString(line) 61 | 62 | if strings.HasSuffix(line, ";") { 63 | query.Truncate(query.Len() - 1) 64 | 65 | b := []byte{} 66 | if out, err := run(query.String()); err != nil { 67 | // This error likely corresponds to the query, so instead of exiting 68 | // interactive mode, we simply write the error to stdout and proceed. 69 | b = append(b, []byte(err.Error())...) 70 | b = append(b, '\a', '\n') 71 | term.Write(b) 72 | } else if len(out) > 0 { 73 | _, h, err := terminal.GetSize(fd) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | b = append(b, []byte(out)...) 79 | 80 | // Write to stdout if out is less than 3/4 of the height of the 81 | // window OR the `less` command doesn't exist; otherwise, invoke the 82 | // pager. 83 | if float64(strings.Count(out, "\n")) <= 0.75*float64(h) || 84 | !pager.CommandExists() { 85 | term.Write(b) 86 | } else if err = pager.New(b); err != nil { 87 | return err 88 | } 89 | } 90 | 91 | query.Reset() 92 | } 93 | 94 | prompt = "... " 95 | if query.Len() == 0 { 96 | prompt = ">>> " 97 | } 98 | term.SetPrompt(prompt) 99 | } 100 | return nil 101 | } 102 | 103 | // run invokes fsql.Run with the provided query string. 104 | func run(query string) (out string, err error) { 105 | stdout := os.Stdout 106 | r, w, err := os.Pipe() 107 | if err != nil { 108 | return "", err 109 | } 110 | os.Stdout = w 111 | defer func() { os.Stdout = stdout }() 112 | 113 | ch := make(chan string) 114 | go func() { 115 | var buf bytes.Buffer 116 | io.Copy(&buf, r) 117 | ch <- buf.String() 118 | }() 119 | 120 | err = fsql.Run(query) 121 | // Must happen after the function call and before we try to read from ch. 122 | if closeErr := w.Close(); closeErr != nil { 123 | return "", closeErr 124 | } 125 | out = <-ch 126 | return 127 | } 128 | -------------------------------------------------------------------------------- /terminal/terminal_test.go: -------------------------------------------------------------------------------- 1 | package terminal 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestRun(t *testing.T) { 10 | type Expected struct { 11 | out string 12 | err error 13 | } 14 | 15 | type Case struct { 16 | query string 17 | expected Expected 18 | } 19 | 20 | // We already test core functionality in the fsql package, so we can stick 21 | // with _simple_ queries for the following cases. 22 | cases := []Case{ 23 | { 24 | query: "select name, hash from ../testdata where name = baz", 25 | expected: Expected{ 26 | out: "baz\tda39a3e\n", 27 | err: nil, 28 | }, 29 | }, 30 | { 31 | query: "select all from", 32 | expected: Expected{ 33 | out: "", 34 | err: errors.New("unexpected EOF"), 35 | }, 36 | }, 37 | } 38 | 39 | for _, c := range cases { 40 | actual, err := run(c.query) 41 | if c.expected.err == nil { 42 | if err != nil { 43 | t.Fatalf("\nExpected no error\n Got %v", err) 44 | } 45 | if !reflect.DeepEqual(c.expected.out, actual) { 46 | t.Fatalf("\nExpected %v\n Got %v", c.expected.out, actual) 47 | } 48 | } else if !reflect.DeepEqual(c.expected.err, err) { 49 | t.Fatalf("\nExpected %v\n Got %v", c.expected.err, err) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /testdata/bar/corge: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kashav/fsql/27e06ef3550e5b7b45c8f95c68cfcf0e39a4ab48/testdata/bar/corge -------------------------------------------------------------------------------- /testdata/bar/garply/xyzzy/thud/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kashav/fsql/27e06ef3550e5b7b45c8f95c68cfcf0e39a4ab48/testdata/bar/garply/xyzzy/thud/.gitkeep -------------------------------------------------------------------------------- /testdata/bar/grault: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kashav/fsql/27e06ef3550e5b7b45c8f95c68cfcf0e39a4ab48/testdata/bar/grault -------------------------------------------------------------------------------- /testdata/baz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kashav/fsql/27e06ef3550e5b7b45c8f95c68cfcf0e39a4ab48/testdata/baz -------------------------------------------------------------------------------- /testdata/foo/quux: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kashav/fsql/27e06ef3550e5b7b45c8f95c68cfcf0e39a4ab48/testdata/foo/quux -------------------------------------------------------------------------------- /testdata/foo/quuz/fred/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kashav/fsql/27e06ef3550e5b7b45c8f95c68cfcf0e39a4ab48/testdata/foo/quuz/fred/.gitkeep -------------------------------------------------------------------------------- /testdata/foo/quuz/waldo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kashav/fsql/27e06ef3550e5b7b45c8f95c68cfcf0e39a4ab48/testdata/foo/quuz/waldo -------------------------------------------------------------------------------- /testdata/foo/qux: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kashav/fsql/27e06ef3550e5b7b45c8f95c68cfcf0e39a4ab48/testdata/foo/qux -------------------------------------------------------------------------------- /tokenizer/token.go: -------------------------------------------------------------------------------- 1 | package tokenizer 2 | 3 | import "fmt" 4 | 5 | // TokenType represents a Token's type. 6 | type TokenType int8 7 | 8 | // All TokenType constants. 9 | const ( 10 | Unknown TokenType = iota 11 | 12 | Identifier 13 | Subquery 14 | 15 | Select 16 | From 17 | Where 18 | 19 | As 20 | Or 21 | And 22 | Not 23 | 24 | In 25 | Is 26 | Like 27 | RLike 28 | 29 | Equals 30 | NotEquals 31 | GreaterThanEquals 32 | GreaterThan 33 | LessThanEquals 34 | LessThan 35 | 36 | Comma 37 | Hyphen 38 | ExclamationMark 39 | OpenParen 40 | CloseParen 41 | OpenBracket 42 | CloseBracket 43 | ) 44 | 45 | func (t TokenType) String() string { 46 | switch t { 47 | case Identifier: 48 | return "identifier" 49 | case Subquery: 50 | return "subquery" 51 | case Select: 52 | return "select" 53 | case From: 54 | return "from" 55 | case As: 56 | return "as" 57 | case Where: 58 | return "where" 59 | case Or: 60 | return "or" 61 | case And: 62 | return "and" 63 | case Not: 64 | return "not" 65 | case In: 66 | return "in" 67 | case Is: 68 | return "is" 69 | case Like: 70 | return "like" 71 | case RLike: 72 | return "RLike" 73 | case Equals: 74 | return "equal" 75 | case NotEquals: 76 | return "not-equal" 77 | case GreaterThanEquals: 78 | return "greater-than-or-equal" 79 | case GreaterThan: 80 | return "greater-than" 81 | case LessThanEquals: 82 | return "less-than-or-equal" 83 | case LessThan: 84 | return "less-than" 85 | case Comma: 86 | return "comma" 87 | case Hyphen: 88 | return "hyphen" 89 | case ExclamationMark: 90 | return "exclamation-mark" 91 | case OpenParen: 92 | return "open-parentheses" 93 | case CloseParen: 94 | return "close-parentheses" 95 | case OpenBracket: 96 | return "open-bracket" 97 | case CloseBracket: 98 | return "close-bracket" 99 | default: 100 | return "unknown" 101 | } 102 | } 103 | 104 | // Token represents a single token. 105 | type Token struct { 106 | Type TokenType 107 | Raw string 108 | } 109 | 110 | func (t *Token) String() string { 111 | return fmt.Sprintf("{type: %s, raw: \"%s\"}", 112 | t.Type.String(), t.Raw) 113 | } 114 | 115 | // Tokenizer represents a token worker. 116 | type Tokenizer struct { 117 | input []rune 118 | tokens []*Token 119 | } 120 | 121 | // NewTokenizer initializes a new Tokenizer. 122 | func NewTokenizer(input string) *Tokenizer { 123 | return &Tokenizer{ 124 | input: []rune(input), 125 | tokens: make([]*Token, 0), 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /tokenizer/token_test.go: -------------------------------------------------------------------------------- 1 | package tokenizer 2 | 3 | import "testing" 4 | 5 | func TestToken_TokenTypeString(t *testing.T) { 6 | type Case struct { 7 | tt TokenType 8 | expected string 9 | } 10 | 11 | cases := []Case{ 12 | {tt: Identifier, expected: "identifier"}, 13 | {tt: Subquery, expected: "subquery"}, 14 | {tt: Select, expected: "select"}, 15 | {tt: From, expected: "from"}, 16 | {tt: As, expected: "as"}, 17 | {tt: Where, expected: "where"}, 18 | {tt: Or, expected: "or"}, 19 | {tt: And, expected: "and"}, 20 | {tt: Not, expected: "not"}, 21 | {tt: In, expected: "in"}, 22 | {tt: Is, expected: "is"}, 23 | {tt: Like, expected: "like"}, 24 | {tt: RLike, expected: "RLike"}, 25 | {tt: Equals, expected: "equal"}, 26 | {tt: NotEquals, expected: "not-equal"}, 27 | {tt: GreaterThanEquals, expected: "greater-than-or-equal"}, 28 | {tt: GreaterThan, expected: "greater-than"}, 29 | {tt: LessThanEquals, expected: "less-than-or-equal"}, 30 | {tt: LessThan, expected: "less-than"}, 31 | {tt: Comma, expected: "comma"}, 32 | {tt: Hyphen, expected: "hyphen"}, 33 | {tt: ExclamationMark, expected: "exclamation-mark"}, 34 | {tt: OpenParen, expected: "open-parentheses"}, 35 | {tt: CloseParen, expected: "close-parentheses"}, 36 | {tt: OpenBracket, expected: "open-bracket"}, 37 | {tt: CloseBracket, expected: "close-bracket"}, 38 | {tt: Unknown, expected: "unknown"}, 39 | } 40 | 41 | for _, c := range cases { 42 | actual := c.tt.String() 43 | if c.expected != actual { 44 | t.Fatalf("\nExpected %v\n Got %v", c.expected, actual) 45 | } 46 | } 47 | } 48 | 49 | func TestToken_String(t *testing.T) { 50 | type Case struct { 51 | token Token 52 | expected string 53 | } 54 | 55 | cases := []Case{ 56 | { 57 | token: Token{Type: Identifier, Raw: "name"}, 58 | expected: "{type: identifier, raw: \"name\"}", 59 | }, 60 | { 61 | token: Token{Type: Comma, Raw: ","}, 62 | expected: "{type: comma, raw: \",\"}", 63 | }, 64 | } 65 | 66 | for _, c := range cases { 67 | actual := c.token.String() 68 | if c.expected != actual { 69 | t.Fatalf("\nExpected %v\n Got %v", c.expected, actual) 70 | } 71 | } 72 | } 73 | 74 | func TestToken_NewTokenizer(t *testing.T) { 75 | input := "SELECT all FROM ." 76 | inputLength, tokenLength := len([]rune(input)), 0 77 | 78 | tokenizer := NewTokenizer(input) 79 | if len(tokenizer.input) != inputLength { 80 | t.Fatalf("len(tokenizer.input)\nExpected %v\n Got %v", inputLength, 81 | len(tokenizer.input)) 82 | } 83 | if len(tokenizer.tokens) != tokenLength { 84 | t.Fatalf("len(tokenizer.tokens)\nExpected %v\n Got %v", tokenLength, 85 | len(tokenizer.tokens)) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tokenizer/tokenizer.go: -------------------------------------------------------------------------------- 1 | package tokenizer 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "unicode" 7 | ) 8 | 9 | // All parses all tokens for this Tokenizer. 10 | func (t *Tokenizer) All() []Token { 11 | tokens := []Token{} 12 | for tok := t.Next(); tok != nil; tok = t.Next() { 13 | tokens = append(tokens, *tok) 14 | } 15 | return tokens 16 | } 17 | 18 | // Next finds and returns the next Token in the input string. 19 | func (t *Tokenizer) Next() *Token { 20 | for unicode.IsSpace(t.current()) { 21 | t.input = t.input[1:] 22 | } 23 | 24 | current := t.current() 25 | if current == -1 { 26 | return nil 27 | } 28 | 29 | switch current { 30 | case '(': 31 | t.input = t.input[1:] 32 | return t.setToken(&Token{Type: OpenParen, Raw: "("}) 33 | case ')': 34 | t.input = t.input[1:] 35 | return t.setToken(&Token{Type: CloseParen, Raw: ")"}) 36 | case '[': 37 | t.input = t.input[1:] 38 | return t.setToken(&Token{Type: OpenBracket, Raw: "["}) 39 | case ']': 40 | t.input = t.input[1:] 41 | return t.setToken(&Token{Type: CloseBracket, Raw: "]"}) 42 | case ',': 43 | t.input = t.input[1:] 44 | return t.setToken(&Token{Type: Comma, Raw: ","}) 45 | case '-': 46 | t.input = t.input[1:] 47 | return t.setToken(&Token{Type: Hyphen, Raw: "-"}) 48 | case '!': 49 | if t.getRuneAt(1) == '=' { 50 | t.input = t.input[2:] 51 | return t.setToken(&Token{Type: NotEquals, Raw: "!="}) 52 | } 53 | return t.setToken(&Token{Type: ExclamationMark, Raw: "!"}) 54 | case '=': 55 | t.input = t.input[1:] 56 | return t.setToken(&Token{Type: Equals, Raw: "="}) 57 | case '>': 58 | if t.getRuneAt(1) == '=' { 59 | t.input = t.input[2:] 60 | return t.setToken(&Token{Type: GreaterThanEquals, Raw: ">="}) 61 | } 62 | t.input = t.input[1:] 63 | return t.setToken(&Token{Type: GreaterThan, Raw: ">"}) 64 | case '<': 65 | if t.getRuneAt(1) == '=' { 66 | t.input = t.input[2:] 67 | return t.setToken(&Token{Type: LessThanEquals, Raw: "<="}) 68 | } 69 | if t.getRuneAt(1) == '>' { 70 | t.input = t.input[2:] 71 | return t.setToken(&Token{Type: NotEquals, Raw: "<>"}) 72 | } 73 | t.input = t.input[1:] 74 | return t.setToken(&Token{Type: LessThan, Raw: "<"}) 75 | } 76 | 77 | if !t.currentIs(-1, ',', '\'', '"', '`', '(', ')', '[', ']') { 78 | word := t.readWord() 79 | tok := &Token{Raw: word} 80 | 81 | switch strings.ToUpper(word) { 82 | case "SELECT": 83 | tok.Type = Select 84 | case "FROM": 85 | tok.Type = From 86 | case "WHERE": 87 | tok.Type = Where 88 | case "AS": 89 | tok.Type = As 90 | case "OR": 91 | tok.Type = Or 92 | case "AND": 93 | tok.Type = And 94 | case "NOT": 95 | tok.Type = Not 96 | case "IN": 97 | tok.Type = In 98 | case "IS": 99 | tok.Type = Is 100 | case "LIKE": 101 | tok.Type = Like 102 | case "REGEXP", "RLIKE": 103 | tok.Type = RLike 104 | default: 105 | tok.Type = Identifier 106 | } 107 | 108 | if t.getPreviousToken() != nil && t.getPreviousToken().Type == OpenParen && 109 | t.getTokenAt(1) != nil && t.getTokenAt(1).Type == In { 110 | // The two previous tokens were: `IN` and `(`, so we're at a subquery. 111 | tok.Type = Subquery 112 | tok.Raw = fmt.Sprintf("%s %s", word, t.readQuery()) 113 | } 114 | 115 | return t.setToken(tok) 116 | } 117 | 118 | tok := &Token{Type: Unknown, Raw: string(current)} 119 | 120 | // If the current rune is a single/double quote or backtick, we want to keep 121 | // reading until we reach the matching closing symbol. 122 | if t.currentIs('\'', '"', '`') { 123 | t.input = t.input[1:] 124 | tok.Raw = t.readWord() + t.readUntil(current) 125 | tok.Type = Identifier 126 | } 127 | 128 | t.input = t.input[1:] 129 | return t.setToken(tok) 130 | } 131 | 132 | // setToken adds token to the list of this Tokenizer's tokens. 133 | func (t *Tokenizer) setToken(token *Token) *Token { 134 | t.tokens = append(t.tokens, token) 135 | return token 136 | } 137 | 138 | // getPreviousToken returns the token that was most-recently read. 139 | func (t *Tokenizer) getPreviousToken() *Token { 140 | return t.getTokenAt(0) 141 | } 142 | 143 | // getTokenAt returns the token at index i from the end of the tokens slice. 144 | func (t *Tokenizer) getTokenAt(i int) *Token { 145 | j := len(t.tokens) - 1 - i 146 | if j < 0 { 147 | return nil 148 | } 149 | 150 | return t.tokens[j] 151 | } 152 | 153 | // current returns the run at the 0th index of the input. 154 | func (t *Tokenizer) current() rune { 155 | return t.getRuneAt(0) 156 | } 157 | 158 | // getRuneAt returns the rune at the ith index of the input. 159 | func (t *Tokenizer) getRuneAt(i int) rune { 160 | if len(t.input) == i { 161 | return -1 162 | } 163 | 164 | return t.input[i] 165 | } 166 | 167 | // currentIs returns true iff the input's current rune (at index 0) is in runes. 168 | func (t *Tokenizer) currentIs(runes ...rune) bool { 169 | for _, r := range runes { 170 | if r == t.current() { 171 | return true 172 | } 173 | } 174 | return false 175 | } 176 | 177 | // readWord reads a single word from the input. Returns when the next rune is 178 | // any of: nil (-1), empty space, comma, single/double quote, backtick, 179 | // or opening/closing parenthesis/bracket. 180 | func (t *Tokenizer) readWord() string { 181 | word := []rune{} 182 | 183 | for { 184 | if unicode.IsSpace(t.current()) || 185 | t.currentIs(-1, ',', '\'', '"', '`', '(', ')', '[', ']') { 186 | return string(word) 187 | } 188 | 189 | word = append(word, t.current()) 190 | t.input = t.input[1:] 191 | } 192 | } 193 | 194 | // readQuery reads a full string until reaching a closing parentheses. Counts 195 | // opening parens to ensure that balance is maintained. 196 | func (t *Tokenizer) readQuery() string { 197 | var query string 198 | 199 | var count = 1 200 | for count > 0 { 201 | for unicode.IsSpace(t.current()) { 202 | t.input = t.input[1:] 203 | } 204 | 205 | word := fmt.Sprintf("%s", t.readWord()) 206 | 207 | if t.current() == -1 { 208 | break 209 | } 210 | 211 | if t.current() == '(' { 212 | count++ 213 | word = "(" 214 | } else if t.current() == ')' { 215 | count-- 216 | if count <= 0 { 217 | query += word 218 | break 219 | } 220 | word = ")" 221 | } else if t.currentIs('\'', '`') { 222 | word += string(t.current()) 223 | } else { 224 | word += " " 225 | } 226 | 227 | query += word 228 | t.input = t.input[1:] 229 | } 230 | 231 | return query 232 | } 233 | 234 | // readUnitl reads the input starting at start, until reaching a rune in runes. 235 | func (t *Tokenizer) readUntil(runes ...rune) string { 236 | var word string 237 | for !t.currentIs(runes...) { 238 | for unicode.IsSpace(t.current()) { 239 | t.input = t.input[1:] 240 | } 241 | word = fmt.Sprintf("%s %s", word, t.readWord()) 242 | } 243 | return word 244 | } 245 | -------------------------------------------------------------------------------- /tokenizer/tokenizer_test.go: -------------------------------------------------------------------------------- 1 | package tokenizer 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestTokenizer_NextTokenType(t *testing.T) { 9 | type Case struct { 10 | input string 11 | expected TokenType 12 | } 13 | 14 | cases := []Case{ 15 | {input: "SELECT", expected: Select}, 16 | {input: "FROM", expected: From}, 17 | {input: "WHERE", expected: Where}, 18 | {input: "AS", expected: As}, 19 | {input: "OR", expected: Or}, 20 | {input: "AND", expected: And}, 21 | {input: "NOT", expected: Not}, 22 | {input: "IN", expected: In}, 23 | {input: "IS", expected: Is}, 24 | {input: "LIKE", expected: Like}, 25 | {input: "RLIKE", expected: RLike}, 26 | {input: "foo", expected: Identifier}, 27 | {input: "(", expected: OpenParen}, 28 | {input: ")", expected: CloseParen}, 29 | {input: ",", expected: Comma}, 30 | {input: "-", expected: Hyphen}, 31 | {input: "=", expected: Equals}, 32 | {input: "<>", expected: NotEquals}, 33 | {input: "<", expected: LessThan}, 34 | {input: "<=", expected: LessThanEquals}, 35 | {input: ">", expected: GreaterThan}, 36 | {input: ">=", expected: GreaterThanEquals}, 37 | } 38 | 39 | for _, c := range cases { 40 | actual := NewTokenizer(c.input).Next() 41 | expected := &Token{Type: c.expected, Raw: c.input} 42 | if !reflect.DeepEqual(actual, expected) { 43 | t.Fatalf("\nExpected: %v\n Got: %v", expected, actual) 44 | } 45 | } 46 | } 47 | 48 | func TestTokenizer_NextRaw(t *testing.T) { 49 | type Case struct { 50 | input string 51 | expected string 52 | } 53 | 54 | // TODO: Fix the last 2 cases, they're currently hanging. 55 | cases := []Case{ 56 | {input: "foo", expected: "foo"}, 57 | {input: " foo ", expected: "foo"}, 58 | {input: "\" foo \"", expected: " foo "}, 59 | {input: "' foo '", expected: " foo "}, 60 | {input: "` foo `", expected: " foo "}, 61 | // Case{input: "\"foo'bar\"", expected: "foo'bar"}, 62 | // Case{input: "\"()\"", expected: "()"}, 63 | } 64 | 65 | for _, c := range cases { 66 | actual := NewTokenizer(c.input).Next() 67 | expected := &Token{Type: Identifier, Raw: c.expected} 68 | if !reflect.DeepEqual(actual, expected) { 69 | t.Fatalf("\nExpected: %v\n Got: %v", expected, actual) 70 | } 71 | } 72 | } 73 | 74 | func TestTokenizer_AllSimple(t *testing.T) { 75 | input := ` 76 | SELECT 77 | name, size 78 | FROM 79 | ~/Desktop 80 | WHERE 81 | name LIKE %go 82 | ` 83 | 84 | actual := NewTokenizer(input).All() 85 | expected := []Token{ 86 | {Type: Select, Raw: "SELECT"}, 87 | {Type: Identifier, Raw: "name"}, 88 | {Type: Comma, Raw: ","}, 89 | {Type: Identifier, Raw: "size"}, 90 | {Type: From, Raw: "FROM"}, 91 | {Type: Identifier, Raw: "~/Desktop"}, 92 | {Type: Where, Raw: "WHERE"}, 93 | {Type: Identifier, Raw: "name"}, 94 | {Type: Like, Raw: "LIKE"}, 95 | {Type: Identifier, Raw: "%go"}, 96 | } 97 | 98 | for i := range expected { 99 | if !reflect.DeepEqual(actual[i], expected[i]) { 100 | t.Fatalf("\nExpected: %v\n Got: %v", expected[i], actual[i]) 101 | } 102 | } 103 | } 104 | 105 | func TestTokenizer_AllSubquery(t *testing.T) { 106 | input := ` 107 | SELECT 108 | name, size 109 | FROM 110 | ~/Desktop 111 | WHERE 112 | name LIKE %go OR 113 | name IN ( 114 | SELECT 115 | name 116 | FROM 117 | $GOPATH/src/github.com 118 | WHERE 119 | name RLIKE .*_test\.go) 120 | ` 121 | 122 | actual := NewTokenizer(input).All() 123 | expected := []Token{ 124 | {Type: Select, Raw: "SELECT"}, 125 | {Type: Identifier, Raw: "name"}, 126 | {Type: Comma, Raw: ","}, 127 | {Type: Identifier, Raw: "size"}, 128 | {Type: From, Raw: "FROM"}, 129 | {Type: Identifier, Raw: "~/Desktop"}, 130 | {Type: Where, Raw: "WHERE"}, 131 | {Type: Identifier, Raw: "name"}, 132 | {Type: Like, Raw: "LIKE"}, 133 | {Type: Identifier, Raw: "%go"}, 134 | {Type: Or, Raw: "OR"}, 135 | {Type: Identifier, Raw: "name"}, 136 | {Type: In, Raw: "IN"}, 137 | {Type: OpenParen, Raw: "("}, 138 | {Type: Subquery, Raw: "SELECT name FROM $GOPATH/src/github.com WHERE name RLIKE .*_test\\.go"}, 139 | {Type: CloseParen, Raw: ")"}, 140 | } 141 | 142 | for i := range expected { 143 | if !reflect.DeepEqual(actual[i], expected[i]) { 144 | t.Fatalf("\nExpected: %v\n Got: %v", expected[i], actual[i]) 145 | } 146 | } 147 | } 148 | 149 | func TestTokenizer_ReadWord(t *testing.T) { 150 | type Case struct { 151 | input string 152 | expected string 153 | } 154 | 155 | cases := []Case{ 156 | {input: "foo", expected: "foo"}, 157 | {input: "foo bar", expected: "foo"}, 158 | {input: "", expected: ""}, 159 | } 160 | 161 | for _, c := range cases { 162 | actual := NewTokenizer(c.input).readWord() 163 | if !reflect.DeepEqual(actual, c.expected) { 164 | t.Fatalf("\nExpected: %v\n Got: %v", c.expected, actual) 165 | } 166 | } 167 | } 168 | 169 | func TestTokenizer_ReadQuery(t *testing.T) { 170 | type Case struct { 171 | input string 172 | expected string 173 | } 174 | 175 | // TODO: Complete these cases. 176 | cases := []Case{} 177 | 178 | for _, c := range cases { 179 | actual := NewTokenizer(c.input).readQuery() 180 | if !reflect.DeepEqual(actual, c.expected) { 181 | t.Fatalf("\nExpected: %v\n Got: %v", c.expected, actual) 182 | } 183 | } 184 | } 185 | 186 | func TestTokenizer_ReadUntil(t *testing.T) { 187 | type Case struct { 188 | input string 189 | until []rune 190 | expected string 191 | } 192 | 193 | // TODO: Complete these cases. 194 | cases := []Case{} 195 | 196 | for _, c := range cases { 197 | actual := NewTokenizer(c.input).readUntil(c.until...) 198 | if !reflect.DeepEqual(actual, c.expected) { 199 | t.Fatalf("\nExpected: %v\n Got: %v", c.expected, actual) 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /transform/common.go: -------------------------------------------------------------------------------- 1 | package transform 2 | 3 | import ( 4 | "crypto/sha1" 5 | "encoding/hex" 6 | "hash" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | // formatName runs the correct name format function based on the value of arg. 13 | func formatName(arg, name string) interface{} { 14 | switch strings.ToUpper(arg) { 15 | case "UPPER": 16 | return upper(name) 17 | case "LOWER": 18 | return lower(name) 19 | } 20 | return nil 21 | } 22 | 23 | // upper returns the uppercased version of name. 24 | func upper(name string) interface{} { 25 | return strings.ToUpper(name) 26 | } 27 | 28 | // lower returns the lowercase version of name. 29 | func lower(name string) interface{} { 30 | return strings.ToLower(name) 31 | } 32 | 33 | // truncate returns the first n characters of str. If n is greater than the 34 | // length of str or less than 0, return str. 35 | func truncate(str string, n int) string { 36 | if len(str) < n || n < 0 { 37 | return str 38 | } 39 | 40 | return str[0:n] 41 | } 42 | 43 | // FindHash returns a func to create a new hash based on the provided name. 44 | func FindHash(name string) func() hash.Hash { 45 | switch strings.ToUpper(name) { 46 | case "SHA1": 47 | return sha1.New 48 | } 49 | return nil 50 | } 51 | 52 | // ComputeHash applies the hash h to the file located at path. Returns a line 53 | // of dashes for directories. 54 | func ComputeHash(info os.FileInfo, path string, h hash.Hash) (interface{}, error) { 55 | fallback := strings.Repeat("-", h.Size()*2) 56 | 57 | // If the current file is a symlink, attempt to evaluate the link and 58 | // stat the resultant file. If either process fails, ignore the error and 59 | // return the fallback. 60 | if info.Mode()&os.ModeSymlink == os.ModeSymlink { 61 | var err error 62 | if path, err = filepath.EvalSymlinks(path); err != nil { 63 | return fallback, nil 64 | } 65 | if info, err = os.Stat(path); err != nil { 66 | return fallback, nil 67 | } 68 | } 69 | 70 | if info.IsDir() { 71 | return fallback, nil 72 | } 73 | 74 | b, err := os.ReadFile(path) 75 | if err != nil { 76 | return nil, err 77 | } 78 | if _, err := h.Write(b); err != nil { 79 | return nil, err 80 | } 81 | return hex.EncodeToString(h.Sum(nil)), nil 82 | } 83 | -------------------------------------------------------------------------------- /transform/common_test.go: -------------------------------------------------------------------------------- 1 | package transform 2 | 3 | import ( 4 | "crypto/sha1" 5 | "hash" 6 | "os" 7 | "reflect" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestCommon_FormatName(t *testing.T) { 13 | type Case struct { 14 | arg string 15 | name string 16 | expected string 17 | } 18 | 19 | cases := []Case{ 20 | {arg: "upper", name: "foo", expected: "FOO"}, 21 | {arg: "upper", name: "FOO", expected: "FOO"}, 22 | {arg: "lower", name: "foo", expected: "foo"}, 23 | {arg: "lower", name: "FOO", expected: "foo"}, 24 | } 25 | 26 | for _, c := range cases { 27 | result := formatName(c.arg, c.name) 28 | if result != c.expected { 29 | t.Fatalf("\nExpected: %s\n Got: %s", c.expected, result) 30 | } 31 | } 32 | } 33 | 34 | func TestCommon_Upper(t *testing.T) { 35 | type Case struct { 36 | name string 37 | expected string 38 | } 39 | 40 | cases := []Case{ 41 | {name: "foo", expected: "FOO"}, 42 | {name: "FOO", expected: "FOO"}, 43 | } 44 | 45 | for _, c := range cases { 46 | result := upper(c.name) 47 | if result != c.expected { 48 | t.Fatalf("\nExpected: %s\n Got: %s", c.expected, result) 49 | } 50 | } 51 | } 52 | 53 | func TestCommon_Lower(t *testing.T) { 54 | type Case struct { 55 | name string 56 | expected string 57 | } 58 | 59 | cases := []Case{ 60 | {name: "foo", expected: "foo"}, 61 | {name: "FOO", expected: "foo"}, 62 | } 63 | 64 | for _, c := range cases { 65 | result := lower(c.name) 66 | if result != c.expected { 67 | t.Fatalf("\nExpected: %s\n Got: %s", c.expected, result) 68 | } 69 | } 70 | } 71 | 72 | func TestCommon_Truncate(t *testing.T) { 73 | input := "foo-bar-baz" 74 | 75 | type Case struct { 76 | n int 77 | expected string 78 | } 79 | 80 | cases := []Case{ 81 | {n: 3, expected: "foo"}, 82 | {n: 7, expected: "foo-bar"}, 83 | {n: 100, expected: input}, 84 | {n: -1, expected: input}, 85 | {n: len(input), expected: "foo-bar-baz"}, 86 | {n: len(input) - 1, expected: "foo-bar-ba"}, 87 | } 88 | 89 | for _, c := range cases { 90 | actual := truncate(input, c.n) 91 | if c.expected != actual { 92 | t.Fatalf("\nExpected: %s\n Got: %s", c.expected, actual) 93 | } 94 | } 95 | } 96 | 97 | func TestCommon_FindHash(t *testing.T) { 98 | type Case struct { 99 | name string 100 | expected hash.Hash 101 | } 102 | 103 | cases := []Case{ 104 | {name: "SHA1", expected: sha1.New()}, 105 | {name: "FOO", expected: nil}, 106 | } 107 | 108 | for _, c := range cases { 109 | actual := FindHash(c.name) 110 | if actual == nil { 111 | if c.expected != nil { 112 | t.Fatalf("\nExpected: %s\n Got: nil", c.expected) 113 | } 114 | } else if h := actual(); !reflect.DeepEqual(c.expected, h) { 115 | t.Fatalf("\nExpected: %s\n Got: %s", c.expected, h) 116 | } 117 | } 118 | } 119 | 120 | func TestCommon_ComputeHash(t *testing.T) { 121 | type Case struct { 122 | path string 123 | expected string 124 | } 125 | 126 | cases := []Case{ 127 | {path: "../testdata/foo", expected: strings.Repeat("-", 40)}, 128 | {path: "../testdata/baz", expected: "da39a3ee5e6b4b0d3255bfef95601890afd80709"}, 129 | } 130 | 131 | for _, c := range cases { 132 | info, err := os.Stat(c.path) 133 | if err != nil { 134 | t.Fatalf("\nExpected no error\n Got: %s", err.Error()) 135 | } 136 | actual, err := ComputeHash(info, c.path, sha1.New()) 137 | if err != nil { 138 | t.Fatalf("\nExpected no error\n Got: %s", err.Error()) 139 | } 140 | if !reflect.DeepEqual(c.expected, actual) { 141 | t.Fatalf("\nExpected: %s\n Got: %s", c.expected, actual) 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /transform/error.go: -------------------------------------------------------------------------------- 1 | package transform 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // ErrNotImplemented used for non-implemented modifier functions. 9 | type ErrNotImplemented struct { 10 | Name string 11 | Attribute string 12 | } 13 | 14 | func (e *ErrNotImplemented) Error() string { 15 | return fmt.Sprintf("function %s is not implemented for attribute %s", 16 | strings.ToUpper(e.Name), e.Attribute) 17 | } 18 | 19 | // ErrUnsupportedFormat used for unsupport arguments for FORMAT functions. 20 | type ErrUnsupportedFormat struct { 21 | Format string 22 | Attribute string 23 | } 24 | 25 | func (e *ErrUnsupportedFormat) Error() string { 26 | return fmt.Sprintf("unsupported format type %s for attribute %s", 27 | e.Format, e.Attribute) 28 | } 29 | -------------------------------------------------------------------------------- /transform/error_test.go: -------------------------------------------------------------------------------- 1 | package transform 2 | 3 | import "testing" 4 | 5 | func TestTransform_ErrNotImplemented(t *testing.T) { 6 | err := &ErrNotImplemented{"n", "a"} 7 | expected := "function N is not implemented for attribute a" 8 | actual := err.Error() 9 | if expected != actual { 10 | t.Fatalf("\nExpected: %s\n Got: %s", expected, actual) 11 | } 12 | } 13 | 14 | func TestTransform_ErrUnsupportedFormat(t *testing.T) { 15 | err := &ErrUnsupportedFormat{"f", "a"} 16 | expected := "unsupported format type f for attribute a" 17 | actual := err.Error() 18 | if expected != actual { 19 | t.Fatalf("\nExpected: %s\n Got: %s", expected, actual) 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /transform/format.go: -------------------------------------------------------------------------------- 1 | package transform 2 | 3 | import ( 4 | "fmt" 5 | "hash" 6 | "os" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | const defaultHashLength = 7 13 | 14 | // FormatParams holds the params for a format-modifier function. 15 | type FormatParams struct { 16 | Attribute string 17 | Path string 18 | Info os.FileInfo 19 | Value interface{} 20 | 21 | Name string 22 | Args []string 23 | } 24 | 25 | // Format runs the respective format function on the provided parameters. 26 | func Format(p *FormatParams) (val interface{}, err error) { 27 | switch strings.ToUpper(p.Name) { 28 | case "FORMAT": 29 | val, err = p.format() 30 | case "UPPER": 31 | val = upper(p.Value.(string)) 32 | case "LOWER": 33 | val = lower(p.Value.(string)) 34 | case "FULLPATH": 35 | val, err = p.fullPath() 36 | case "SHORTPATH": 37 | val, err = p.shortPath() 38 | case "SHA1": 39 | val, err = p.hash(FindHash(p.Name)()) 40 | } 41 | if err != nil { 42 | return nil, err 43 | } 44 | if val == nil { 45 | return nil, &ErrNotImplemented{p.Name, p.Attribute} 46 | } 47 | return val, nil 48 | } 49 | 50 | // format runs a format function based on the value of the provided attribute. 51 | func (p *FormatParams) format() (val interface{}, err error) { 52 | switch p.Attribute { 53 | case "name": 54 | val = formatName(p.Args[0], p.Value.(string)) 55 | case "size": 56 | val, err = p.formatSize() 57 | case "time": 58 | val, err = p.formatTime() 59 | } 60 | if err != nil { 61 | return nil, err 62 | } 63 | if val == nil { 64 | return nil, &ErrUnsupportedFormat{p.Args[0], p.Attribute} 65 | } 66 | return val, nil 67 | } 68 | 69 | // formatSize formats a size. Valid arguments include `KB`, `MB`, `GB` (case 70 | // insensitive). 71 | func (p *FormatParams) formatSize() (interface{}, error) { 72 | size := p.Value.(int64) 73 | switch strings.ToUpper(p.Args[0]) { 74 | case "KB": 75 | return fmt.Sprintf("%fkb", float64(size)/(1<<10)), nil 76 | case "MB": 77 | return fmt.Sprintf("%fmb", float64(size)/(1<<20)), nil 78 | case "GB": 79 | return fmt.Sprintf("%fgb", float64(size)/(1<<30)), nil 80 | } 81 | return nil, nil 82 | } 83 | 84 | // formatTime formats a time. Valid arguments include `UNIX` and `ISO` (case 85 | // insensitive), or a custom layout layout. If a custom layout is provided, it 86 | // must be set according to 2006-01-02T15:04:05.999999-07:00. 87 | func (p *FormatParams) formatTime() (interface{}, error) { 88 | switch strings.ToUpper(p.Args[0]) { 89 | case "ISO": 90 | return p.Info.ModTime().Format(time.RFC3339), nil 91 | case "UNIX": 92 | return p.Info.ModTime().Format(time.UnixDate), nil 93 | default: 94 | return p.Info.ModTime().Format(p.Args[0]), nil 95 | } 96 | } 97 | 98 | // fullPath returns the full path of the current file. Only supports the 99 | // `name` attribute. 100 | func (p *FormatParams) fullPath() (interface{}, error) { 101 | if p.Attribute != "name" { 102 | return nil, nil 103 | } 104 | return p.Path, nil 105 | } 106 | 107 | // shortPath returns the short path of the current file. Only supports the 108 | // `name` attribute. 109 | func (p *FormatParams) shortPath() (interface{}, error) { 110 | if p.Attribute != "name" { 111 | return nil, nil 112 | } 113 | return p.Info.Name(), nil 114 | } 115 | 116 | // hash applies the provided hash algorithm h with ComputeHash. 117 | func (p *FormatParams) hash(h hash.Hash) (interface{}, error) { 118 | var ( 119 | err error 120 | n int 121 | result interface{} 122 | ) 123 | 124 | if len(p.Args) == 0 || p.Args[0] == "" { 125 | n = defaultHashLength 126 | } else if strings.ToUpper(p.Args[0]) == "FULL" { 127 | n = -1 128 | } else if n, err = strconv.Atoi(p.Args[0]); err != nil { 129 | return nil, err 130 | } 131 | 132 | if result, err = ComputeHash(p.Info, p.Path, h); err != nil { 133 | return nil, err 134 | } 135 | 136 | return truncate(result.(string), n), nil 137 | } 138 | 139 | // DefaultFormatValue returns the default format value for the provided 140 | // attribute attr based on path and info. 141 | func DefaultFormatValue(attr, path string, info os.FileInfo) (value interface{}, err error) { 142 | switch attr { 143 | case "mode": 144 | value = info.Mode() 145 | case "name": 146 | value = info.Name() 147 | case "size": 148 | value = info.Size() 149 | case "time": 150 | value = info.ModTime().Format(time.Stamp) 151 | case "hash": 152 | if value, err = ComputeHash(info, path, FindHash("SHA1")()); value != nil { 153 | value = truncate(value.(string), defaultHashLength) 154 | } 155 | default: 156 | err = fmt.Errorf("unknown attribute %s", attr) 157 | } 158 | return value, err 159 | } 160 | -------------------------------------------------------------------------------- /transform/format_test.go: -------------------------------------------------------------------------------- 1 | package transform 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestTransform_Format(t *testing.T) { 10 | type Expected struct { 11 | val interface{} 12 | err error 13 | } 14 | 15 | type Case struct { 16 | params *FormatParams 17 | expected Expected 18 | } 19 | 20 | // TODO: Add tests for the time and hash attribute. 21 | cases := []Case{ 22 | { 23 | params: &FormatParams{ 24 | Attribute: "size", 25 | Path: "path", 26 | Info: nil, 27 | Value: int64(300), 28 | Name: "format", 29 | Args: []string{"kb"}, 30 | }, 31 | expected: Expected{ 32 | val: fmt.Sprintf("%fkb", float64(300)/(1<<10)), 33 | err: nil, 34 | }, 35 | }, 36 | { 37 | params: &FormatParams{ 38 | Attribute: "size", 39 | Path: "path", 40 | Info: nil, 41 | Value: int64(300), 42 | Name: "format", 43 | Args: []string{"kilobytes"}, 44 | }, 45 | expected: Expected{ 46 | val: nil, 47 | err: &ErrUnsupportedFormat{"kilobytes", "size"}, 48 | }, 49 | }, 50 | { 51 | params: &FormatParams{ 52 | Attribute: "name", 53 | Path: "path", 54 | Info: nil, 55 | Value: "VALUE", 56 | Name: "format", 57 | Args: []string{"lower"}, 58 | }, 59 | expected: Expected{val: "value", err: nil}, 60 | }, 61 | { 62 | params: &FormatParams{ 63 | Attribute: "name", 64 | Path: "path", 65 | Info: nil, 66 | Value: "value", 67 | Name: "upper", 68 | Args: []string{}, 69 | }, 70 | expected: Expected{val: "VALUE", err: nil}, 71 | }, 72 | { 73 | params: &FormatParams{ 74 | Attribute: "name", 75 | Path: "path", 76 | Info: nil, 77 | Value: "value", 78 | Name: "fullpath", 79 | Args: []string{}, 80 | }, 81 | expected: Expected{val: "path", err: nil}, 82 | }, 83 | } 84 | 85 | for _, c := range cases { 86 | val, err := Format(c.params) 87 | if !(reflect.DeepEqual(val, c.expected.val) && 88 | reflect.DeepEqual(err, c.expected.err)) { 89 | t.Fatalf("\nExpected: %v, %v\n Got: %v, %v", 90 | c.expected.val, c.expected.err, 91 | val, err) 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /transform/parse.go: -------------------------------------------------------------------------------- 1 | package transform 2 | 3 | import ( 4 | "hash" 5 | "os" 6 | "reflect" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // ParseParams holds the params for a parse-modifier function. 13 | type ParseParams struct { 14 | Attribute string 15 | Value interface{} 16 | 17 | Name string 18 | Args []string 19 | } 20 | 21 | // Parse runs the associated modifier function for the provided parameters. 22 | // Depending on the type of p.Value, we may recursively run this method 23 | // on every element of the structure. 24 | // 25 | // We're using reflect _quite_ heavily for this, meaning it's kind of unsafe, 26 | // it'd be great if we could find another solution while keeping it as 27 | // abstract as it is. 28 | func Parse(p *ParseParams) (val interface{}, err error) { 29 | kind := reflect.TypeOf(p.Value).Kind() 30 | 31 | // If we have a slice/array, recursively run Parse on each element. 32 | if kind == reflect.Slice || kind == reflect.Array { 33 | s := reflect.ValueOf(p.Value) 34 | for i := 0; i < s.Len(); i++ { 35 | p.Value = s.Index(i).Interface() 36 | if val, err = Parse(p); err != nil { 37 | return nil, err 38 | } 39 | s.Index(i).Set(reflect.ValueOf(val)) 40 | } 41 | return s.Interface(), nil 42 | } 43 | 44 | // If we have a map, recursively run Parse on each *key* and create a new 45 | // map out of the return values. 46 | if kind == reflect.Map { 47 | result := reflect.MakeMap(reflect.TypeOf(p.Value)) 48 | for _, key := range reflect.ValueOf(p.Value).MapKeys() { 49 | p.Value = key.Interface() 50 | if val, err = Parse(p); err != nil { 51 | return nil, err 52 | } 53 | result.SetMapIndex(reflect.ValueOf(val), reflect.ValueOf(true)) 54 | } 55 | return result.Interface(), nil 56 | } 57 | 58 | // Not a slice nor a map. 59 | switch strings.ToUpper(p.Name) { 60 | case "FORMAT": 61 | val, err = p.format() 62 | case "UPPER": 63 | val = upper(p.Value.(string)) 64 | case "LOWER": 65 | val = lower(p.Value.(string)) 66 | case "SHA1": 67 | val, err = p.hash(FindHash(p.Name)()) 68 | } 69 | 70 | if err != nil { 71 | return nil, err 72 | } 73 | if val == nil { 74 | return nil, &ErrNotImplemented{p.Name, p.Attribute} 75 | } 76 | return val, nil 77 | } 78 | 79 | // format runs the correct format function based on the provided attribute. 80 | func (p *ParseParams) format() (val interface{}, err error) { 81 | switch p.Attribute { 82 | case "name": 83 | val = formatName(p.Args[0], p.Value.(string)) 84 | case "size": 85 | val, err = p.formatSize() 86 | case "time": 87 | val, err = p.formatTime() 88 | } 89 | if err != nil { 90 | return nil, err 91 | } 92 | if val == nil { 93 | return nil, &ErrUnsupportedFormat{p.Args[0], p.Attribute} 94 | } 95 | return val, nil 96 | } 97 | 98 | // formatSize formats the size attribute. Valid arguments include `B`, `KB`, 99 | // `MB`, and `GB` (case insensitive). 100 | func (p *ParseParams) formatSize() (interface{}, error) { 101 | size, err := strconv.ParseFloat(p.Value.(string), 64) 102 | if err != nil { 103 | return nil, err 104 | } 105 | switch strings.ToUpper(p.Args[0]) { 106 | case "B": 107 | size *= 1 108 | case "KB": 109 | size *= 1 << 10 110 | case "MB": 111 | size *= 1 << 20 112 | case "GB": 113 | size *= 1 << 30 114 | default: 115 | return nil, nil 116 | } 117 | return size, nil 118 | } 119 | 120 | // formatTime formats the time attribute. Valid arguments include `ISO`, 121 | // `UNIX`, (case insensitive) or a custom layout. If a custom layout is 122 | // provided, it must be set according to 2006-01-02T15:04:05.999999-07:00. 123 | func (p *ParseParams) formatTime() (interface{}, error) { 124 | var ( 125 | t time.Time 126 | err error 127 | ) 128 | switch strings.ToUpper(p.Args[0]) { 129 | case "ISO": 130 | t, err = time.Parse(time.RFC3339, p.Value.(string)) 131 | case "UNIX": 132 | t, err = time.Parse(time.UnixDate, p.Value.(string)) 133 | default: 134 | t, err = time.Parse(p.Args[0], p.Value.(string)) 135 | } 136 | if err != nil { 137 | return nil, err 138 | } 139 | return t, nil 140 | } 141 | 142 | func (p *ParseParams) hash(h hash.Hash) (interface{}, error) { 143 | info, err := os.Stat(p.Value.(string)) 144 | if err != nil { 145 | return nil, err 146 | } 147 | return ComputeHash(info, p.Value.(string), h) 148 | } 149 | -------------------------------------------------------------------------------- /transform/parse_test.go: -------------------------------------------------------------------------------- 1 | package transform 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | type ParseOutput struct { 9 | val interface{} 10 | err error 11 | } 12 | 13 | type ParseCase struct { 14 | params *ParseParams 15 | expected ParseOutput 16 | } 17 | 18 | func TestTransform_Parse(t *testing.T) { 19 | // TODO: Complete this. 20 | cases := []ParseCase{} 21 | 22 | for _, c := range cases { 23 | val, err := Parse(c.params) 24 | if !(reflect.DeepEqual(val, c.expected.val) && 25 | reflect.DeepEqual(err, c.expected.err)) { 26 | t.Fatalf("\nExpected: %v, %v\n Got: %v, %v", 27 | c.expected.val, c.expected.err, 28 | val, err) 29 | } 30 | } 31 | } 32 | --------------------------------------------------------------------------------