├── .gitignore ├── go.mod ├── go.sum ├── internal ├── models │ └── models.go └── constants │ └── consts.go ├── .github └── ISSUE_TEMPLATE │ ├── custom.md │ ├── feature_request.md │ └── bug_report.md ├── .editorconfig ├── SECURITY.md ├── LICENSE ├── pkg └── inquiry │ ├── inquiry.go │ └── helpers.go ├── CODE_OF_CONDUCT.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.csv 3 | cpu.prof 4 | .idea 5 | main.go 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sionpixley/inquiry 2 | 3 | go 1.22 4 | 5 | require github.com/mattn/go-sqlite3 v1.14.32 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= 2 | github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 3 | -------------------------------------------------------------------------------- /internal/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "reflect" 4 | 5 | type FieldTagMap struct { 6 | Field reflect.StructField 7 | Tag Tag 8 | } 9 | 10 | type Tag int 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 2 10 | 11 | [{*.go,go.mod,go.sum}] 12 | indent_style = tab 13 | indent_size = 4 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | indent_size = 4 18 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | For the most up-to-date security policy, please visit the [GitHub repo](https://github.com/sionpixley/inquiry). 4 | 5 | ## Supported Versions 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | v0.x | :white_check_mark: | 10 | 11 | ## Reporting a Vulnerability 12 | 13 | To report a vulnerability, please email sionpixley@gmail.com with a description of the issue, detailed steps on how to reproduce the issue, which versions of Inquiry are affected, and if known, mitigations for the issue. 14 | -------------------------------------------------------------------------------- /internal/constants/consts.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | import "github.com/sionpixley/inquiry/internal/models" 4 | 5 | const ( 6 | FilePathDoesNotExistError string = "inquiry error: file path does not exist" 7 | NoFieldsError string = "inquiry error: struct has no fields" 8 | NotAStructError string = "inquiry error: generic type provided is not a struct" 9 | UnsupportedFileTypeError string = "inquiry error: unsupported field type" 10 | ) 11 | 12 | const ( 13 | OtherTag models.Tag = iota 14 | IndexTag 15 | PrimaryKeyTag 16 | UniqueTag 17 | ) 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sion Pixley 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 | -------------------------------------------------------------------------------- /pkg/inquiry/inquiry.go: -------------------------------------------------------------------------------- 1 | // Package inquiry converts CSV files into a SQLite database, allowing you to run SQL statements on them. 2 | package inquiry 3 | 4 | import ( 5 | "database/sql" 6 | 7 | _ "github.com/mattn/go-sqlite3" 8 | ) 9 | 10 | // CsvOptions is a struct that allows you to configure information about your CSV file. 11 | type CsvOptions struct { 12 | CommentCharacter rune `json:"commentCharacter"` 13 | Delimiter rune `json:"delimiter"` 14 | HasHeaderRow bool `json:"hasHeaderRow"` 15 | TrimLeadingSpace bool `json:"trimLeadingSpace"` 16 | UseLazyQuotes bool `json:"useLazyQuotes"` 17 | } 18 | 19 | /* 20 | Connect function creates an in-memory SQLite database from a CSV file. 21 | It takes the CSV file path as a parameter and returns two things: A pointer to the in-memory SQLite database and an error. 22 | If no errors occur, the returned error will be nil. 23 | 24 | It assumes that the CSV file doesn't have a header row and that the file's delimiter is a comma. 25 | If you need to customize these, please use the function ConnectWithOptions. 26 | */ 27 | func Connect[T any](csvFilePath string) (*sql.DB, error) { 28 | options := CsvOptions{ 29 | Delimiter: ',', 30 | HasHeaderRow: false, 31 | TrimLeadingSpace: false, 32 | UseLazyQuotes: false, 33 | } 34 | return ConnectWithOptions[T](csvFilePath, options) 35 | } 36 | 37 | // ConnectWithOptions function creates an in-memory SQLite database from a CSV file. 38 | // It takes two parameters: the CSV file path and a CsvOptions struct. 39 | // It returns two things: A pointer to the in-memory SQLite database and an error. 40 | // If no errors occur, the returned error will be nil. 41 | func ConnectWithOptions[T any](csvFilePath string, options CsvOptions) (*sql.DB, error) { 42 | db, err := sql.Open("sqlite3", ":memory:") 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | tx, err := db.Begin() 48 | if err != nil { 49 | return nil, err 50 | } 51 | defer tx.Rollback() 52 | 53 | t, err := createTable[T](tx) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | err = insertRows(tx, csvFilePath, t, options) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | err = tx.Commit() 64 | return db, err 65 | } 66 | 67 | /* 68 | CreateTable function creates a new table from a CSV file and adds it to an existing SQLite database. 69 | It takes two parameters: a pointer to the SQLite database and a CSV file path. 70 | It returns an error. If no errors occur, the returned error will be nil. 71 | 72 | It assumes that the CSV file doesn't have a header row and that the file's delimiter is a comma. 73 | If you need to customize these, please use the function CreateTableWithOptions. 74 | */ 75 | func CreateTable[T any](db *sql.DB, csvFilePath string) error { 76 | options := CsvOptions{ 77 | Delimiter: ',', 78 | HasHeaderRow: false, 79 | TrimLeadingSpace: false, 80 | UseLazyQuotes: false, 81 | } 82 | return CreateTableWithOptions[T](db, csvFilePath, options) 83 | } 84 | 85 | // CreateTableWithOptions function creates a new table from a CSV file and adds it to an existing SQLite database. 86 | // It takes three parameters: a pointer to the SQLite database, a CSV file path, and a CsvOptions struct. 87 | // It returns an error. If no errors occur, the returned error will be nil. 88 | func CreateTableWithOptions[T any](db *sql.DB, csvFilePath string, options CsvOptions) error { 89 | tx, err := db.Begin() 90 | if err != nil { 91 | return err 92 | } 93 | defer tx.Rollback() 94 | 95 | t, err := createTable[T](tx) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | err = insertRows(tx, csvFilePath, t, options) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | return tx.Commit() 106 | } 107 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | sionpixley@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /pkg/inquiry/helpers.go: -------------------------------------------------------------------------------- 1 | package inquiry 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/csv" 6 | "errors" 7 | "io" 8 | "os" 9 | "reflect" 10 | "strings" 11 | 12 | "github.com/sionpixley/inquiry/internal/constants" 13 | "github.com/sionpixley/inquiry/internal/models" 14 | ) 15 | 16 | func buildCreateTableStatement(t reflect.Type) (string, []models.FieldTagMap, error) { 17 | if t.Kind() != reflect.Struct { 18 | return "", nil, errors.New(constants.NotAStructError) 19 | } else if t.NumField() == 0 { 20 | return "", nil, errors.New(constants.NoFieldsError) 21 | } 22 | 23 | indexes := []models.FieldTagMap{} 24 | constraints := []models.FieldTagMap{} 25 | 26 | var builder strings.Builder 27 | builder.WriteString("CREATE TABLE '") 28 | builder.WriteString(t.Name()) 29 | builder.WriteString("'(") 30 | for i := range t.NumField() { 31 | field := t.Field(i) 32 | 33 | tags := convertToTags(strings.Split(trimAndToLowerStr(field.Tag.Get("inquiry")), ",")) 34 | for _, tag := range tags { 35 | switch tag { 36 | case constants.IndexTag: 37 | indexes = append(indexes, models.FieldTagMap{Field: field, Tag: tag}) 38 | case constants.PrimaryKeyTag: 39 | constraints = append(constraints, models.FieldTagMap{Field: field, Tag: tag}) 40 | case constants.UniqueTag: 41 | if field.Type.Kind() == reflect.Pointer { 42 | indexes = append(indexes, models.FieldTagMap{Field: field, Tag: tag}) 43 | } else { 44 | constraints = append(constraints, models.FieldTagMap{Field: field, Tag: tag}) 45 | } 46 | default: 47 | // Do nothing. 48 | } 49 | } 50 | 51 | switch field.Type.Kind() { 52 | case reflect.Bool: 53 | builder.WriteString("'") 54 | builder.WriteString(field.Name) 55 | builder.WriteString("' INTEGER NOT NULL CHECK('") 56 | builder.WriteString(field.Name) 57 | builder.WriteString("' IN (0,1)),") 58 | case reflect.Float32: 59 | fallthrough 60 | case reflect.Float64: 61 | builder.WriteString("'") 62 | builder.WriteString(field.Name) 63 | builder.WriteString("' REAL NOT NULL,") 64 | case reflect.Int: 65 | fallthrough 66 | case reflect.Int8: 67 | fallthrough 68 | case reflect.Int16: 69 | fallthrough 70 | case reflect.Int32: 71 | fallthrough 72 | case reflect.Int64: 73 | builder.WriteString("'") 74 | builder.WriteString(field.Name) 75 | builder.WriteString("' INTEGER NOT NULL,") 76 | case reflect.Pointer: 77 | f := field.Type.Elem() 78 | switch f.Kind() { 79 | case reflect.Bool: 80 | builder.WriteString("'") 81 | builder.WriteString(field.Name) 82 | builder.WriteString("' INTEGER NULL CHECK('") 83 | builder.WriteString(field.Name) 84 | builder.WriteString("' IN (0,1)),") 85 | case reflect.Float32: 86 | fallthrough 87 | case reflect.Float64: 88 | builder.WriteString("'") 89 | builder.WriteString(field.Name) 90 | builder.WriteString("' REAL NULL,") 91 | case reflect.Int: 92 | fallthrough 93 | case reflect.Int8: 94 | fallthrough 95 | case reflect.Int16: 96 | fallthrough 97 | case reflect.Int32: 98 | fallthrough 99 | case reflect.Int64: 100 | builder.WriteString("'") 101 | builder.WriteString(field.Name) 102 | builder.WriteString("' INTEGER NULL,") 103 | case reflect.String: 104 | builder.WriteString("'") 105 | builder.WriteString(field.Name) 106 | builder.WriteString("' TEXT NULL,") 107 | default: 108 | return "", nil, errors.New(constants.UnsupportedFileTypeError) 109 | } 110 | case reflect.String: 111 | builder.WriteString("'") 112 | builder.WriteString(field.Name) 113 | builder.WriteString("' TEXT NOT NULL,") 114 | default: 115 | return "", nil, errors.New(constants.UnsupportedFileTypeError) 116 | } 117 | } 118 | 119 | for _, constraint := range constraints { 120 | if constraint.Tag == constants.PrimaryKeyTag { 121 | builder.WriteString("CONSTRAINT ") 122 | builder.WriteString("PK_") 123 | builder.WriteString(t.Name()) 124 | builder.WriteString("_") 125 | builder.WriteString(constraint.Field.Name) 126 | builder.WriteString(" PRIMARY KEY('") 127 | builder.WriteString(constraint.Field.Name) 128 | builder.WriteString("'),") 129 | } else { 130 | builder.WriteString("CONSTRAINT ") 131 | builder.WriteString("Unique_") 132 | builder.WriteString(t.Name()) 133 | builder.WriteString("_") 134 | builder.WriteString(constraint.Field.Name) 135 | builder.WriteString(" UNIQUE('") 136 | builder.WriteString(constraint.Field.Name) 137 | builder.WriteString("'),") 138 | } 139 | } 140 | 141 | statement := builder.String() 142 | statement = strings.TrimSuffix(statement, ",") 143 | statement += ");" 144 | return statement, indexes, nil 145 | } 146 | 147 | func convertToTags(t []string) []models.Tag { 148 | tags := make([]models.Tag, len(t)) 149 | for i, strT := range t { 150 | switch strT { 151 | case "index": 152 | tags[i] = constants.IndexTag 153 | case "primarykey": 154 | tags[i] = constants.PrimaryKeyTag 155 | case "unique": 156 | tags[i] = constants.UniqueTag 157 | default: 158 | tags[i] = constants.OtherTag 159 | } 160 | } 161 | return tags 162 | } 163 | 164 | func createTable[T any](tx *sql.Tx) (reflect.Type, error) { 165 | var zeroValue T 166 | t := reflect.TypeOf(zeroValue) 167 | 168 | createStatement, indexes, err := buildCreateTableStatement(t) 169 | if err != nil { 170 | return nil, err 171 | } 172 | 173 | _, err = tx.Exec(createStatement) 174 | if err != nil { 175 | return nil, err 176 | } 177 | 178 | for _, index := range indexes { 179 | var builder strings.Builder 180 | builder.WriteString("CREATE ") 181 | if index.Tag == constants.IndexTag { 182 | builder.WriteString("INDEX NonClustered_") 183 | builder.WriteString(t.Name()) 184 | builder.WriteString("_") 185 | builder.WriteString(index.Field.Name) 186 | builder.WriteString(" ON '") 187 | builder.WriteString(t.Name()) 188 | builder.WriteString("'('") 189 | builder.WriteString(index.Field.Name) 190 | builder.WriteString("');") 191 | } else { 192 | builder.WriteString("UNIQUE INDEX Unique_") 193 | builder.WriteString(t.Name()) 194 | builder.WriteString("_") 195 | builder.WriteString(index.Field.Name) 196 | builder.WriteString(" ON '") 197 | builder.WriteString(t.Name()) 198 | builder.WriteString("'('") 199 | builder.WriteString(index.Field.Name) 200 | builder.WriteString("') WHERE '") 201 | builder.WriteString(index.Field.Name) 202 | builder.WriteString("' IS NOT NULL;") 203 | } 204 | 205 | _, err = tx.Exec(builder.String()) 206 | if err != nil { 207 | return nil, err 208 | } 209 | } 210 | 211 | return t, err 212 | } 213 | 214 | func insert(tx *sql.Tx, statement string, row []string, t reflect.Type) error { 215 | args := make([]any, t.NumField()) 216 | for i := range t.NumField() { 217 | if trimmedStr := strings.TrimSpace(row[i]); (trimmedStr == "" || trimmedStr == "null" || trimmedStr == "NULL") && t.Field(i).Type.Kind() == reflect.Pointer { 218 | args[i] = nil 219 | } else { 220 | args[i] = any(row[i]) 221 | } 222 | } 223 | 224 | _, err := tx.Exec(statement, args...) 225 | return err 226 | } 227 | 228 | func insertRows(tx *sql.Tx, csvFilePath string, t reflect.Type, options CsvOptions) error { 229 | if _, err := os.Stat(csvFilePath); os.IsNotExist(err) { 230 | return errors.New(constants.FilePathDoesNotExistError) 231 | } else if err != nil { 232 | return err 233 | } 234 | 235 | file, err := os.Open(csvFilePath) 236 | if err != nil { 237 | return err 238 | } 239 | defer file.Close() 240 | 241 | statement, err := prepareStatement(t) 242 | if err != nil { 243 | return err 244 | } 245 | 246 | reader := csv.NewReader(file) 247 | reader.LazyQuotes = options.UseLazyQuotes 248 | reader.TrimLeadingSpace = options.TrimLeadingSpace 249 | if int(options.Delimiter) != 0 { 250 | reader.Comma = options.Delimiter 251 | } 252 | if int(options.CommentCharacter) != 0 { 253 | reader.Comment = options.CommentCharacter 254 | } 255 | 256 | for { 257 | // Skip first loop if there's a header row. 258 | if options.HasHeaderRow { 259 | _, err = reader.Read() 260 | if err == io.EOF { 261 | break 262 | } else if err != nil { 263 | return err 264 | } 265 | options.HasHeaderRow = false 266 | } else { 267 | row, err := reader.Read() 268 | if err == io.EOF { 269 | break 270 | } else if err != nil { 271 | return err 272 | } 273 | 274 | err = insert(tx, statement, row, t) 275 | if err != nil { 276 | return err 277 | } 278 | } 279 | } 280 | 281 | return nil 282 | } 283 | 284 | func prepareStatement(t reflect.Type) (string, error) { 285 | if t.Kind() != reflect.Struct { 286 | return "", errors.New(constants.NotAStructError) 287 | } else if t.NumField() == 0 { 288 | return "", errors.New(constants.NoFieldsError) 289 | } 290 | 291 | builder := strings.Builder{} 292 | builder.WriteString("INSERT INTO '") 293 | builder.WriteString(t.Name()) 294 | builder.WriteString("' VALUES (") 295 | for range t.NumField() { 296 | builder.WriteString("?,") 297 | } 298 | 299 | statement := builder.String() 300 | statement = strings.TrimSuffix(statement, ",") 301 | statement += ");" 302 | 303 | return statement, nil 304 | } 305 | 306 | func trimAndToLowerStr(s string) string { 307 | return strings.ToLower(strings.TrimSpace(s)) 308 | } 309 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Inquiry 2 | 3 | Inquiry is a Go package that converts CSV files into a SQLite database, allowing you to run SQL statements on them. 4 | 5 | ## Table of contents 6 | 7 | 1. [How to install](#how-to-install) 8 | 2. [How to use](#how-to-use) 9 | 1. [Defining your struct](#defining-your-struct) 10 | 1. [Go field to SQLite column mapping](#go-field-to-sqlite-column-mapping) 11 | 2. [Creating an in-memory SQLite database from a CSV file](#creating-an-in-memory-sqlite-database-from-a-csv-file) 12 | 1. [Without options](#without-options) 13 | 2. [With options](#with-options) 14 | 3. [Creating a new table from a CSV file and adding it to an existing SQLite database](#creating-a-new-table-from-a-csv-file-and-adding-it-to-an-existing-sqlite-database) 15 | 1. [Adding a table to an in-memory database from a CSV](#adding-a-table-to-an-in-memory-database-from-a-csv) 16 | 2. [Adding a table to an on-disk database from a CSV](#adding-a-table-to-an-on-disk-database-from-a-csv) 17 | 3. [Contributing](#contributing) 18 | 19 | ## How to install 20 | 21 | `go get github.com/sionpixley/inquiry` 22 | 23 | ## How to use 24 | 25 | Using Inquiry is pretty simple: You "connect" to the CSV file and Inquiry will return a `*sql.DB` and an `error`. You can then use the returned `*sql.DB` to do any operations that you would normally do with a SQLite database. 26 | 27 | You can also create new tables from CSV files and add them to an existing SQLite database (in-memory or not). 28 | 29 | ### Defining your struct 30 | 31 | Inquiry uses generics and package `reflect` to build the SQLite database/table and to insert the data. Please put the struct fields in the same position as the column they are supposed to represent in the CSV file. Different struct definitions yield different SQL `CREATE TABLE` statements. For example: 32 | 33 | ```go 34 | type Student struct { 35 | Id int 36 | FirstName string 37 | MiddleName *string 38 | LastName string 39 | IsFullTime bool 40 | GPA float64 41 | } 42 | ``` 43 | 44 | ```sql 45 | -- SQL CREATE TABLE statement that is generated from the above Go struct definition. 46 | CREATE TABLE 'Student'( 47 | 'Id' INTEGER NOT NULL, 48 | 'FirstName' TEXT NOT NULL, 49 | 'MiddleName' TEXT NULL, 50 | 'LastName' TEXT NOT NULL, 51 | 'IsFullTime' INTEGER NOT NULL CHECK('IsFullTime' IN (0,1)), 52 | 'GPA' REAL NOT NULL 53 | ); 54 | ``` 55 | 56 | Please consult the table below for a full list of which Go field types map to which SQLite column types. 57 | 58 | #### Go field to SQLite column mapping 59 | 60 | | Go Field Type | SQLite Column Type | 61 | | ------------- | --------------- | 62 | | `bool` | `INTEGER NOT NULL CHECK( IN (0,1))` | 63 | | `*bool` | `INTEGER NULL CHECK( IN (0,1))` | 64 | | - `float32`
- `float64` | `REAL NOT NULL` | 65 | | - `*float32`
- `*float64` | `REAL NULL` | 66 | | - `int`
- `int8`
- `int16`
- `int32`
- `int64` | `INTEGER NOT NULL` | 67 | | - `*int`
- `*int8`
- `*int16`
- `*int32`
- `*int64` | `INTEGER NULL` | 68 | | `string` | `TEXT NOT NULL` | 69 | | `*string` | `TEXT NULL` | 70 | 71 | On nullable columns, certain values in the CSV will insert a `NULL` into the database. These values are: an empty value, `null`, and `NULL`. On non-nullable columns, these values can potentially throw an error or be inserted as they are (especially in the case of a `TEXT` column). 72 | 73 | #### Inquiry struct tags 74 | 75 | You can use struct tags to create primary keys, indexes, and unique constraints. 76 | 77 | > **Note:** Inquiry currently only supports single-column indexes and unique constraints. 78 | 79 | ```go 80 | type Customer struct { 81 | Index int `inquiry:"primaryKey"` 82 | CustomerId string `inquiry:"unique"` 83 | FirstName string 84 | LastName string 85 | Company string 86 | City string 87 | Country string 88 | Phone1 string 89 | Phone2 string 90 | Email *string 91 | SubscriptionDate string 92 | Website string `inquiry:"index"` 93 | } 94 | ``` 95 | 96 | ```sql 97 | -- SQL that is generated from the above Go struct definition. 98 | CREATE TABLE 'Customer'( 99 | 'Index' INTEGER NOT NULL, 100 | 'CustomerId' TEXT NOT NULL, 101 | 'FirstName' TEXT NOT NULL, 102 | 'LastName' TEXT NOT NULL, 103 | 'Company' TEXT NOT NULL, 104 | 'City' TEXT NOT NULL, 105 | 'Country' TEXT NOT NULL, 106 | 'Phone1' TEXT NOT NULL, 107 | 'Phone2' TEXT NOT NULL, 108 | 'Email' TEXT NULL, 109 | 'SubscriptionDate' TEXT NOT NULL, 110 | 'Website' TEXT NOT NULL, 111 | 112 | CONSTRAINT PK_Customer_Index PRIMARY KEY('Index'), 113 | CONSTRAINT Unique_Customer_CustomerId UNIQUE('CustomerId') 114 | ); 115 | 116 | CREATE INDEX NonClustered_Customer_Website ON 'Customer'('Website'); 117 | ``` 118 | 119 | ### Creating an in-memory SQLite database from a CSV file 120 | 121 | To create an in-memory database from a CSV file, use the `Connect` or `ConnectWithOptions` function. 122 | 123 | With options, you can specify your CSV delimiter and whether the file has a header row or not. If you don't provide options, Inquiry will default the delimiter to a comma and assume there is no header row. 124 | 125 | #### Without options 126 | 127 | ``` 128 | // example.csv 129 | 130 | 1,hi,2.8 131 | 2,hello,3.4 132 | 3,yo,90.3 133 | 4,happy,100.5 134 | 5,yay,8.1 135 | ``` 136 | 137 | ```go 138 | // main.go 139 | 140 | package main 141 | 142 | import ( 143 | "fmt" 144 | "log" 145 | "os" 146 | 147 | "github.com/sionpixley/inquiry/pkg/inquiry" 148 | ) 149 | 150 | type Example struct { 151 | Id int 152 | Name string 153 | Value float64 154 | } 155 | 156 | func main() { 157 | db, err := inquiry.Connect[Example]("example.csv") 158 | if err != nil { 159 | log.Fatalln(err.Error()) 160 | } 161 | // Don't forget to close the database. 162 | defer db.Close() 163 | 164 | rows, err := db.Query("SELECT * FROM Example WHERE Value > 80 ORDER BY Name ASC;") 165 | if err != nil { 166 | log.Fatalln(err.Error()) 167 | } 168 | 169 | for rows.Next() { 170 | var example Example 171 | err = rows.Scan(&example.Id, &example.Name, &example.Value) 172 | if err != nil { 173 | log.Fatalln(err.Error()) 174 | } 175 | fmt.Printf("%d %s %f", example.Id, example.Name, example.Value) 176 | fmt.Println() 177 | } 178 | } 179 | ``` 180 | 181 | ``` 182 | // output 183 | 184 | 4 happy 100.500000 185 | 3 yo 90.300000 186 | ``` 187 | 188 | #### With options 189 | 190 | The options are set using a struct: `CsvOptions`. Please see below for the definition of the `CsvOptions` struct and an example of using it. 191 | 192 | ```go 193 | type CsvOptions struct { 194 | CommentCharacter rune `json:"commentCharacter"` 195 | Delimiter rune `json:"delimiter"` 196 | HasHeaderRow bool `json:"hasHeaderRow"` 197 | TrimLeadingSpace bool `json:"trimLeadingSpace"` 198 | UseLazyQuotes bool `json:"useLazyQuotes"` 199 | } 200 | ``` 201 | 202 | ``` 203 | // example.csv 204 | 205 | Id|Name|Value 206 | 1|hi|2.8 207 | 2|hello|3.4 208 | 3|yo|90.3 209 | 4|happy|100.5 210 | 5|yay|8.1 211 | ``` 212 | 213 | ```go 214 | // main.go 215 | 216 | package main 217 | 218 | import ( 219 | "fmt" 220 | "log" 221 | "os" 222 | 223 | "github.com/sionpixley/inquiry/pkg/inquiry" 224 | ) 225 | 226 | type Example struct { 227 | Id int 228 | Name string 229 | Value float64 230 | } 231 | 232 | func main() { 233 | options := inquiry.CsvOptions{ 234 | Delimiter: '|', 235 | HasHeaderRow: true, 236 | } 237 | 238 | db, err := inquiry.ConnectWithOptions[Example]("example.csv", options) 239 | if err != nil { 240 | log.Fatalln(err.Error()) 241 | } 242 | // Don't forget to close the database. 243 | defer db.Close() 244 | 245 | rows, err := db.Query("SELECT * FROM Example WHERE Value > 80 ORDER BY Name ASC;") 246 | if err != nil { 247 | log.Fatalln(err.Error()) 248 | } 249 | 250 | for rows.Next() { 251 | var example Example 252 | err = rows.Scan(&example.Id, &example.Name, &example.Value) 253 | if err != nil { 254 | log.Fatalln(err.Error()) 255 | } 256 | fmt.Printf("%d %s %f", example.Id, example.Name, example.Value) 257 | fmt.Println() 258 | } 259 | } 260 | ``` 261 | 262 | ``` 263 | // output 264 | 265 | 4 happy 100.500000 266 | 3 yo 90.300000 267 | ``` 268 | 269 | ### Creating a new table from a CSV file and adding it to an existing SQLite database 270 | 271 | To create a new table from a CSV file and add it to an existing SQLite database, use the `CreateTable` or `CreateTableWithOptions` function. 272 | 273 | With options, you can specify your CSV delimiter and whether the file has a header row or not. If you don't provide options, Inquiry will default the delimiter to a comma and assume there is no header row. 274 | 275 | This works on in-memory databases as well as databases that persist to disk. 276 | 277 | #### Adding a table to an in-memory database from a CSV 278 | 279 | ``` 280 | // example.csv 281 | 282 | Id|Name|Value 283 | 1|hi|2.8 284 | 2|hello|3.4 285 | 3|yo|90.3 286 | 4|happy|100.5 287 | 5|yay|8.1 288 | ``` 289 | 290 | ``` 291 | // test.csv 292 | 293 | 1,this is a horrible test 294 | 2,ehhh 295 | ``` 296 | 297 | ```go 298 | // main.go 299 | 300 | package main 301 | 302 | import ( 303 | "fmt" 304 | "log" 305 | "os" 306 | 307 | "github.com/sionpixley/inquiry/pkg/inquiry" 308 | ) 309 | 310 | type Example struct { 311 | Id int 312 | Name string 313 | Value float64 314 | } 315 | 316 | type Test struct { 317 | Id int 318 | Val string 319 | } 320 | 321 | func main() { 322 | options := inquiry.CsvOptions{ 323 | Delimiter: '|', 324 | HasHeaderRow: true, 325 | } 326 | 327 | db, err := inquiry.ConnectWithOptions[Example]("example.csv", options) 328 | if err != nil { 329 | log.Fatalln(err.Error()) 330 | } 331 | // Don't forget to close the database. 332 | defer db.Close() 333 | 334 | err = inquiry.CreateTable[Test](db, "test.csv") 335 | if err != nil { 336 | log.Fatalln(err.Error()) 337 | } 338 | 339 | rows, err := db.Query("SELECT * FROM Example WHERE Value > 80 ORDER BY Name ASC;") 340 | if err != nil { 341 | log.Fatalln(err.Error()) 342 | } 343 | 344 | for rows.Next() { 345 | var example Example 346 | err = rows.Scan(&example.Id, &example.Name, &example.Value) 347 | if err != nil { 348 | log.Fatalln(err.Error()) 349 | } 350 | fmt.Printf("%d %s %f", example.Id, example.Name, example.Value) 351 | fmt.Println() 352 | } 353 | 354 | rows, err = db.Query("SELECT * FROM Test ORDER BY Id DESC;") 355 | if err != nil { 356 | log.Fatalln(err.Error()) 357 | } 358 | 359 | for rows.Next() { 360 | var test Test 361 | err = rows.Scan(&test.Id, &test.Val) 362 | if err != nil { 363 | log.Fatalln(err.Error()) 364 | } 365 | fmt.Printf("%d %s", test.Id, test.Val) 366 | fmt.Println() 367 | } 368 | } 369 | ``` 370 | 371 | ``` 372 | // output 373 | 374 | 4 happy 100.500000 375 | 3 yo 90.300000 376 | 2 ehhh 377 | 1 this is a horrible test 378 | ``` 379 | 380 | #### Adding a table to an on-disk database from a CSV 381 | 382 | ``` 383 | // example.db is an on-disk database with data equivalent to: 384 | 385 | 1,hi,2.8 386 | 2,hello,3.4 387 | 3,yo,90.3 388 | 4,happy,100.5 389 | 5,yay,8.1 390 | ``` 391 | 392 | ``` 393 | // test.csv 394 | 395 | 1;this is a horrible test 396 | 2;ehhh 397 | ``` 398 | 399 | ```go 400 | // main.go 401 | 402 | package main 403 | 404 | import ( 405 | "database/sql" 406 | "fmt" 407 | "log" 408 | "os" 409 | 410 | _ "github.com/mattn/go-sqlite3" 411 | "github.com/sionpixley/inquiry/pkg/inquiry" 412 | ) 413 | 414 | type Example struct { 415 | Id int 416 | Name string 417 | Value float64 418 | } 419 | 420 | type Test struct { 421 | Id int 422 | Val string 423 | } 424 | 425 | func main() { 426 | db, err := sql.Open("sqlite3", "example.db") 427 | if err != nil { 428 | log.Fatalln(err.Error()) 429 | } 430 | // Don't forget to close the database. 431 | defer db.Close() 432 | 433 | options := inquiry.CsvOptions{ 434 | Delimiter: ';', 435 | HasHeaderRow: false, 436 | } 437 | 438 | err = inquiry.CreateTableWithOptions[Test](db, "test.csv", options) 439 | if err != nil { 440 | log.Fatalln(err.Error()) 441 | } 442 | 443 | rows, err := db.Query("SELECT * FROM Example WHERE Value > 80 ORDER BY Name ASC;") 444 | if err != nil { 445 | log.Fatalln(err.Error()) 446 | } 447 | 448 | for rows.Next() { 449 | var example Example 450 | err = rows.Scan(&example.Id, &example.Name, &example.Value) 451 | if err != nil { 452 | log.Fatalln(err.Error()) 453 | } 454 | fmt.Printf("%d %s %f", example.Id, example.Name, example.Value) 455 | fmt.Println() 456 | } 457 | 458 | rows, err = db.Query("SELECT * FROM Test ORDER BY Id DESC;") 459 | if err != nil { 460 | log.Fatalln(err.Error()) 461 | } 462 | 463 | for rows.Next() { 464 | var test Test 465 | err = rows.Scan(&test.Id, &test.Val) 466 | if err != nil { 467 | log.Fatalln(err.Error()) 468 | } 469 | fmt.Printf("%d %s", test.Id, test.Val) 470 | fmt.Println() 471 | } 472 | } 473 | ``` 474 | 475 | ``` 476 | // output 477 | 478 | 4 happy 100.500000 479 | 3 yo 90.300000 480 | 2 ehhh 481 | 1 this is a horrible test 482 | ``` 483 | 484 | ## Contributing 485 | 486 | All contributions are welcome! If you wish to contribute to the project, the best way would be forking this repo and making a pull request from your fork with all of your suggested changes. 487 | --------------------------------------------------------------------------------