this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore: Switch to GORM for database abstraction.

+396 -1137
+3 -11
README.md
··· 23 23 24 24 ### Database Migrations 25 25 26 - Tumble uses [golang-migrate](https://github.com/golang-migrate/migrate) to manage database schemas. Migrations are automatically applied on application startup. 26 + Tumble uses **GORM AutoMigrate** to manage database schemas. Migrations are automatically applied on application startup. 27 27 28 - - **Location**: Migration files are stored in `sql/mysql/` and `sql/sqlite/`. 29 - - **Versioning**: Files are named sequentially (e.g., `000001_init.up.sql`). 30 - - **Adding Migrations**: To change the schema, create a new numbered `.up.sql` file in the appropriate directory. 28 + - **Mechanism**: The application checks the database schema on startup and creates/updates tables to match the Go structs in `internal/data/store.go`. 31 29 32 30 ### Testing Infrastructure 33 31 ··· 113 111 114 112 ### 2. Initialize Database 115 113 116 - Run the setup script (works for both MySQL and SQLite): 117 - 118 - ```bash 119 - perl scripts/setup_database.pl 120 - ``` 121 - 122 - That's it! The script will automatically detect your database type and create the necessary tables. 114 + Simply start the application! Tumble will automatically detect your database type (configured in `config.yaml` or via env vars) and create the necessary tables if they don't exist. 123 115 124 116 ### 3. Configure Web Server 125 117
+20 -10
go.mod
··· 3 3 go 1.25.5 4 4 5 5 require ( 6 - github.com/go-sql-driver/mysql v1.9.3 7 - github.com/golang-migrate/migrate/v4 v4.19.1 8 6 github.com/spf13/viper v1.21.0 9 7 golang.org/x/net v0.48.0 10 - modernc.org/sqlite v1.43.0 8 + gorm.io/driver/mysql v1.6.0 9 + gorm.io/driver/sqlite v1.6.0 10 + gorm.io/gorm v1.31.1 11 11 ) 12 12 13 13 require ( 14 14 filippo.io/edwards25519 v1.1.0 // indirect 15 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 15 16 github.com/dustin/go-humanize v1.0.1 // indirect 16 17 github.com/fsnotify/fsnotify v1.9.0 // indirect 18 + github.com/glebarez/go-sqlite v1.21.2 // indirect 19 + github.com/glebarez/sqlite v1.11.0 // indirect 20 + github.com/go-sql-driver/mysql v1.9.3 // indirect 17 21 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 18 - github.com/google/uuid v1.6.0 // indirect 19 - github.com/mattn/go-isatty v0.0.20 // indirect 20 - github.com/ncruces/go-strftime v0.1.9 // indirect 22 + github.com/google/go-cmp v0.7.0 // indirect 23 + github.com/google/uuid v1.3.0 // indirect 24 + github.com/jinzhu/inflection v1.0.0 // indirect 25 + github.com/jinzhu/now v1.1.5 // indirect 26 + github.com/mattn/go-isatty v0.0.17 // indirect 27 + github.com/mattn/go-sqlite3 v1.14.22 // indirect 21 28 github.com/pelletier/go-toml/v2 v2.2.4 // indirect 29 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 22 30 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 31 + github.com/rogpeppe/go-internal v1.13.1 // indirect 23 32 github.com/sagikazarmark/locafero v0.11.0 // indirect 24 33 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect 25 34 github.com/spf13/afero v1.15.0 // indirect ··· 27 36 github.com/spf13/pflag v1.0.10 // indirect 28 37 github.com/subosito/gotenv v1.6.0 // indirect 29 38 go.yaml.in/yaml/v3 v3.0.4 // indirect 30 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 31 39 golang.org/x/sys v0.39.0 // indirect 32 40 golang.org/x/text v0.32.0 // indirect 33 - modernc.org/libc v1.66.10 // indirect 34 - modernc.org/mathutil v1.7.1 // indirect 35 - modernc.org/memory v1.11.0 // indirect 41 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 42 + modernc.org/libc v1.22.5 // indirect 43 + modernc.org/mathutil v1.5.0 // indirect 44 + modernc.org/memory v1.5.0 // indirect 45 + modernc.org/sqlite v1.23.1 // indirect 36 46 )
+39 -101
go.sum
··· 1 1 filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 2 filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= 4 - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 5 - github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 6 - github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 7 - github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 8 - github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 9 - github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= 10 - github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= 11 3 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 12 4 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 - github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= 14 - github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= 15 - github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 16 - github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 17 - github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= 18 - github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 19 - github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= 20 - github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= 21 - github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 22 - github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 23 5 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 24 6 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 25 - github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 26 - github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 27 7 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 28 8 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 29 9 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 30 10 github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 31 - github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 32 - github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 33 - github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 34 - github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 11 + github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= 12 + github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= 13 + github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= 14 + github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= 35 15 github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= 36 16 github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 37 17 github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 38 18 github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 39 - github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 40 - github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 41 - github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= 42 - github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= 43 - github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 44 - github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 45 - github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 46 - github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 47 - github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 48 - github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 19 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 20 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 21 + github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 22 + github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 23 + github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 24 + github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 25 + github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 26 + github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 27 + github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 49 28 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 50 29 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 30 + github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 31 + github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 51 32 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 52 33 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 53 - github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 54 - github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 55 - github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 56 - github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 57 - github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 58 - github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 59 - github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= 60 - github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= 61 - github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 62 - github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 63 - github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 64 - github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 65 - github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 66 - github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 67 - github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= 68 - github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= 34 + github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 35 + github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 36 + github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 37 + github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 69 38 github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 70 39 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 71 - github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 72 - github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 73 40 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 74 41 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 42 + github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 75 43 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 76 44 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 77 - github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 78 - github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 45 + github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 46 + github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 79 47 github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= 80 48 github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= 81 49 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= ··· 92 60 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 93 61 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 94 62 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 95 - go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 96 - go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 97 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= 98 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= 99 - go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= 100 - go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= 101 - go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= 102 - go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= 103 - go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= 104 - go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 105 63 go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 106 64 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 107 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 108 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 109 - golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= 110 - golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= 111 65 golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= 112 66 golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= 113 - golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 114 - golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 115 - golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 + golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 116 68 golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 117 69 golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 118 70 golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 119 71 golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 120 - golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= 121 - golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= 122 72 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 123 - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 124 - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 73 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 74 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 125 75 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 126 76 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 127 - modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= 128 - modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 129 - modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= 130 - modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= 131 - modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= 132 - modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 133 - modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 134 - modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 135 - modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= 136 - modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 137 - modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= 138 - modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= 139 - modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 140 - modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 141 - modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 142 - modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 143 - modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 144 - modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 145 - modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 146 - modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 147 - modernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA= 148 - modernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= 149 - modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 150 - modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 151 - modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 152 - modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 77 + gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= 78 + gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= 79 + gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= 80 + gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= 81 + gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= 82 + gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= 83 + modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= 84 + modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= 85 + modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= 86 + modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= 87 + modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= 88 + modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= 89 + modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= 90 + modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
+18 -5
internal/data/factory.go
··· 3 3 import ( 4 4 "fmt" 5 5 "strings" 6 + 7 + "github.com/glebarez/sqlite" 8 + "gorm.io/driver/mysql" 9 + "gorm.io/gorm" 10 + "gorm.io/gorm/logger" 6 11 ) 7 12 8 13 // NewStore creates a new Store based on the driver name and DSN. 9 14 // Driver can be "mysql" or "sqlite" (case-insensitive). 10 15 func NewStore(driver, dsn string) (Store, error) { 16 + var dialector gorm.Dialector 17 + 11 18 switch strings.ToLower(driver) { 12 19 case "mysql": 13 - return NewMySQLStore(dsn) 20 + dialector = mysql.Open(dsn) 14 21 case "sqlite", "sqlite3": 15 - // modernc.org/sqlite registers as "sqlite" 16 - // If user config says "sqlite3", we map it. 17 - // NOTE: NewSQLiteStore uses "sqlite" internally now. 18 - return NewSQLiteStore(dsn) 22 + dialector = sqlite.Open(dsn) 19 23 default: 20 24 return nil, fmt.Errorf("unknown database driver: %s", driver) 21 25 } 26 + 27 + db, err := gorm.Open(dialector, &gorm.Config{ 28 + Logger: logger.Default.LogMode(logger.Info), 29 + }) 30 + if err != nil { 31 + return nil, err 32 + } 33 + 34 + return NewGormStore(db), nil 22 35 }
+284
internal/data/gorm_store.go
··· 1 + package data 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "time" 7 + 8 + "gorm.io/gorm" 9 + "gorm.io/gorm/clause" 10 + ) 11 + 12 + type GormStore struct { 13 + db *gorm.DB 14 + } 15 + 16 + func NewGormStore(db *gorm.DB) *GormStore { 17 + return &GormStore{db: db} 18 + } 19 + 20 + func (s *GormStore) Close() error { 21 + sqlDB, err := s.db.DB() 22 + if err != nil { 23 + return err 24 + } 25 + return sqlDB.Close() 26 + } 27 + 28 + func (s *GormStore) Bootstrap(ctx context.Context) error { 29 + return s.db.AutoMigrate(&IRCLink{}, &Image{}, &Quote{}) 30 + } 31 + 32 + func (s *GormStore) GetRecentIRCLinks(ctx context.Context, startDays int, endDays int) ([]IRCLink, error) { 33 + var links []IRCLink 34 + // timestamp >= NOW() - startDays AND timestamp <= NOW() - endDays 35 + // Note: startDays is "further back" (larger number), endDays is "closer" (smaller number) 36 + now := time.Now() 37 + startDate := now.AddDate(0, 0, -startDays) 38 + endDate := now.AddDate(0, 0, -endDays) 39 + 40 + err := s.db.WithContext(ctx). 41 + Where("timestamp >= ? AND timestamp <= ?", startDate, endDate). 42 + Order("timestamp DESC"). 43 + Find(&links).Error 44 + return links, err 45 + } 46 + 47 + func (s *GormStore) GetRecentImages(ctx context.Context, startDays int, endDays int) ([]Image, error) { 48 + var images []Image 49 + now := time.Now() 50 + startDate := now.AddDate(0, 0, -startDays) 51 + endDate := now.AddDate(0, 0, -endDays) 52 + 53 + err := s.db.WithContext(ctx). 54 + Where("timestamp >= ? AND timestamp <= ?", startDate, endDate). 55 + Order("timestamp DESC"). 56 + Find(&images).Error 57 + return images, err 58 + } 59 + 60 + func (s *GormStore) GetRecentQuotes(ctx context.Context, startDays int, endDays int) ([]Quote, error) { 61 + var quotes []Quote 62 + now := time.Now() 63 + startDate := now.AddDate(0, 0, -startDays) 64 + endDate := now.AddDate(0, 0, -endDays) 65 + 66 + err := s.db.WithContext(ctx). 67 + Where("timestamp >= ? AND timestamp <= ?", startDate, endDate). 68 + Order("timestamp DESC"). 69 + Find(&quotes).Error 70 + return quotes, err 71 + } 72 + 73 + func (s *GormStore) SearchIRCLinks(ctx context.Context, query string) ([]IRCLink, error) { 74 + var links []IRCLink 75 + // Simple LIKE search for cross-db compatibility 76 + term := "%" + query + "%" 77 + err := s.db.WithContext(ctx). 78 + Where("title LIKE ? OR url LIKE ?", term, term). 79 + Order("clicks DESC"). 80 + Limit(50). 81 + Find(&links).Error 82 + return links, err 83 + } 84 + 85 + func (s *GormStore) GetTopIRCLinks(ctx context.Context, startDays int, endDays int, limit int) ([]IRCLink, error) { 86 + var links []IRCLink 87 + now := time.Now() 88 + startDate := now.AddDate(0, 0, -startDays) 89 + endDate := now.AddDate(0, 0, -endDays) 90 + 91 + err := s.db.WithContext(ctx). 92 + Where("timestamp >= ? AND timestamp <= ? AND clicks > 1", startDate, endDate). 93 + Order("clicks DESC"). 94 + Limit(limit). 95 + Find(&links).Error 96 + return links, err 97 + } 98 + 99 + func (s *GormStore) GetIRCLinkByID(ctx context.Context, id int) (*IRCLink, error) { 100 + var link IRCLink 101 + err := s.db.WithContext(ctx).First(&link, id).Error 102 + if err != nil { 103 + if err == gorm.ErrRecordNotFound { 104 + return nil, nil // Or specific error? Store interface implication seems to be nil on not found or error 105 + } 106 + return nil, err 107 + } 108 + return &link, nil 109 + } 110 + 111 + func (s *GormStore) GetIRCLinkURL(ctx context.Context, id int) (string, error) { 112 + var link IRCLink 113 + // Select only URL to optimize? 114 + err := s.db.WithContext(ctx).Select("url").First(&link, id).Error 115 + return link.URL, err 116 + } 117 + 118 + func (s *GormStore) IncrementClicks(ctx context.Context, id int) error { 119 + return s.db.WithContext(ctx).Model(&IRCLink{}).Where("ircLinkID = ?", id).UpdateColumn("clicks", gorm.Expr("clicks + ?", 1)).Error 120 + } 121 + 122 + func (s *GormStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) { 123 + link := IRCLink{ 124 + User: user, 125 + Title: title, 126 + URL: url, 127 + ContentType: contentType, 128 + Timestamp: time.Now(), 129 + Clicks: 0, 130 + } 131 + err := s.db.WithContext(ctx).Create(&link).Error 132 + return link.ID, err 133 + } 134 + 135 + func (s *GormStore) InsertQuote(ctx context.Context, quoteText, author string) error { 136 + quote := Quote{ 137 + Quote: quoteText, 138 + Author: author, 139 + Timestamp: time.Now(), 140 + } 141 + return s.db.WithContext(ctx).Create(&quote).Error 142 + } 143 + 144 + func (s *GormStore) GetRandomQuote(ctx context.Context) (*Quote, error) { 145 + var quote Quote 146 + // DB agnostic random order 147 + // MySQL: RAND(), SQLite: RANDOM(), Postgres: RANDOM() 148 + orderBy := "RAND()" 149 + if s.db.Dialector.Name() == "sqlite" { 150 + orderBy = "RANDOM()" 151 + } else if s.db.Dialector.Name() == "postgres" { 152 + orderBy = "RANDOM()" 153 + } 154 + 155 + err := s.db.WithContext(ctx).Order(clause.Expr{SQL: orderBy}).First(&quote).Error 156 + return &quote, err 157 + } 158 + 159 + func (s *GormStore) GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]UserStat, error) { 160 + var stats []UserStat 161 + 162 + // Complex aggregation, implementation depends on SQL dialect but standard SQL should work 163 + query := ` 164 + SELECT 165 + u.user, 166 + COALESCE(l.count, 0) as link_count, 167 + COALESCE(q.count, 0) as quote_count 168 + FROM 169 + (SELECT DISTINCT user FROM ircLink UNION SELECT DISTINCT author as user FROM quote) u 170 + LEFT JOIN 171 + (SELECT user, COUNT(*) as count FROM ircLink GROUP BY user) l ON u.user = l.user 172 + LEFT JOIN 173 + (SELECT author, COUNT(*) as count FROM quote GROUP BY author) q ON u.user = q.author 174 + ` 175 + 176 + orderBy := "link_count DESC" 177 + switch sortBy { 178 + case "user": 179 + orderBy = "u.user ASC" 180 + case "quotes": 181 + orderBy = "quote_count DESC" 182 + case "links": 183 + orderBy = "link_count DESC" 184 + } 185 + 186 + query += " ORDER BY " + orderBy 187 + 188 + // GORM raw query with limit offset 189 + err := s.db.WithContext(ctx).Raw(query).Scan(&stats).Error 190 + 191 + // If the raw query doesn't support Limit/Offset directly in GORM chaining for Raw, we need to append it. 192 + // But let's limit slice manually or append SQL? 193 + // Appending SQL is safer for pagination on DB side. 194 + // Re-implementing query construction properly: 195 + 196 + // Since we are using Raw, we must include LIMIT/OFFSET in the SQL string or use a subquery approach with GORM. 197 + // Sticking to Raw string manipulation for this complex query. 198 + 199 + // To perform limit/offset on the result of the UNION/JOIN, it's best to wrap it? 200 + // Or just append. 201 + 202 + // NOTE: GORM's `Scan` will map columns to struct fields nicely. 203 + 204 + // For pagination, we can slice the result if dataset is small, but DB pagination is better. 205 + // Let's use `Limit` and `Offset` methods on the *result*? No, `Raw` executes. 206 + 207 + query += fmt.Sprintf(" LIMIT %d OFFSET %d", limit, offset) 208 + err = s.db.WithContext(ctx).Raw(query).Scan(&stats).Error 209 + 210 + return stats, err 211 + } 212 + 213 + func (s *GormStore) GetLinksByUser(ctx context.Context, user string, limit int, offset int) ([]IRCLink, error) { 214 + var links []IRCLink 215 + err := s.db.WithContext(ctx). 216 + Where("user = ?", user). 217 + Order("timestamp DESC"). 218 + Limit(limit). 219 + Offset(offset). 220 + Find(&links).Error 221 + return links, err 222 + } 223 + 224 + // Helper struct for Timeline UNION scan 225 + type timelineResult struct { 226 + Type string 227 + ID int 228 + Timestamp time.Time 229 + Title string 230 + URL string 231 + Content string 232 + Author string // User for links, Author for quotes 233 + MD5Sum string 234 + } 235 + 236 + func (s *GormStore) GetUserTimeline(ctx context.Context, user string, filterType string, limit int, offset int) ([]TimelineItem, error) { 237 + var results []TimelineItem 238 + 239 + linkSelect := "SELECT 'link' as type, ircLinkID as id, timestamp, title, url, '' as content, user as author, '' as md5sum FROM ircLink WHERE user = ?" 240 + quoteSelect := "SELECT 'quote' as type, quoteID as id, timestamp, '' as title, '' as url, quote as content, author as author, '' as md5sum FROM quote WHERE author = ?" 241 + 242 + var query string 243 + var args []interface{} 244 + 245 + if filterType == "links" { 246 + query = linkSelect 247 + args = append(args, user) 248 + } else if filterType == "quotes" { 249 + query = quoteSelect 250 + args = append(args, user) 251 + } else { 252 + query = linkSelect + " UNION ALL " + quoteSelect 253 + args = append(args, user, user) 254 + } 255 + 256 + query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?" 257 + args = append(args, limit, offset) 258 + 259 + err := s.db.WithContext(ctx).Raw(query, args...).Scan(&results).Error 260 + return results, err 261 + } 262 + 263 + func (s *GormStore) GetGlobalTimeline(ctx context.Context, limit int, offset int) ([]TimelineItem, error) { 264 + var results []TimelineItem 265 + 266 + query := ` 267 + SELECT 268 + 'link' as type, ircLinkID as id, timestamp, title, url, '' as content, user as author, '' as md5sum 269 + FROM ircLink 270 + UNION ALL 271 + SELECT 272 + 'quote' as type, quoteID as id, timestamp, '' as title, '' as url, quote as content, author as author, '' as md5sum 273 + FROM quote 274 + UNION ALL 275 + SELECT 276 + 'image' as type, imageID as id, timestamp, title, url, '' as content, '' as author, md5sum 277 + FROM image 278 + ORDER BY timestamp DESC 279 + LIMIT ? OFFSET ? 280 + ` 281 + 282 + err := s.db.WithContext(ctx).Raw(query, limit, offset).Scan(&results).Error 283 + return results, err 284 + }
-59
internal/data/migrations.go
··· 1 - package data 2 - 3 - import ( 4 - "database/sql" 5 - "fmt" 6 - "log/slog" 7 - 8 - "github.com/golang-migrate/migrate/v4" 9 - "github.com/golang-migrate/migrate/v4/database" 10 - "github.com/golang-migrate/migrate/v4/database/mysql" 11 - "github.com/golang-migrate/migrate/v4/source/iofs" 12 - 13 - tumblesql "tumble/sql" 14 - ) 15 - 16 - // RunMigrations executes pending migrations. 17 - // driverName should be "mysql" or "sqlite" 18 - func RunMigrations(db *sql.DB, driverName string, databaseName string) error { 19 - slog.Info("Running migrations", "driver", driverName) 20 - 21 - sourceDriver, err := iofs.New(tumblesql.MigrationFS, driverName) 22 - if err != nil { 23 - return fmt.Errorf("failed to create iofs source: %w", err) 24 - } 25 - 26 - var databaseDriver database.Driver 27 - 28 - switch driverName { 29 - case "mysql": 30 - databaseDriver, err = mysql.WithInstance(db, &mysql.Config{}) 31 - if err != nil { 32 - return fmt.Errorf("failed to create mysql driver: %w", err) 33 - } 34 - case "sqlite": 35 - // Custom driver for modernc/sqlite (no CGO) 36 - // We implement this in sqlite_driver.go 37 - databaseDriver, err = WithInstance(db, &Config{}) 38 - if err != nil { 39 - return fmt.Errorf("failed to create sqlite driver: %w", err) 40 - } 41 - default: 42 - return fmt.Errorf("unsupported driver: %s", driverName) 43 - } 44 - 45 - m, err := migrate.NewWithInstance( 46 - "iofs", sourceDriver, 47 - driverName, databaseDriver, 48 - ) 49 - if err != nil { 50 - return fmt.Errorf("failed to create migrate instance: %w", err) 51 - } 52 - 53 - if err := m.Up(); err != nil && err != migrate.ErrNoChange { 54 - return fmt.Errorf("failed to run up migrations: %w", err) 55 - } 56 - 57 - slog.Info("Migrations completed successfully") 58 - return nil 59 - }
-402
internal/data/mysql.go
··· 1 - package data 2 - 3 - import ( 4 - "context" 5 - "database/sql" 6 - "fmt" 7 - "log/slog" 8 - "time" 9 - 10 - _ "github.com/go-sql-driver/mysql" 11 - ) 12 - 13 - type MySQLStore struct { 14 - db *sql.DB 15 - } 16 - 17 - func NewMySQLStore(dsn string) (*MySQLStore, error) { 18 - db, err := sql.Open("mysql", dsn) 19 - if err != nil { 20 - return nil, err 21 - } 22 - if err := db.Ping(); err != nil { 23 - return nil, err 24 - } 25 - // Recommended settings 26 - db.SetConnMaxLifetime(time.Minute * 3) 27 - db.SetMaxOpenConns(10) 28 - db.SetMaxIdleConns(10) 29 - 30 - return &MySQLStore{db: db}, nil 31 - } 32 - 33 - func (s *MySQLStore) Close() error { 34 - return s.db.Close() 35 - } 36 - 37 - func (s *MySQLStore) GetRecentIRCLinks(ctx context.Context, startDays int, endDays int) ([]IRCLink, error) { 38 - query := ` 39 - SELECT ircLinkID, timestamp, user, title, url, clicks, content_type 40 - FROM ircLink 41 - WHERE timestamp >= DATE_SUB(NOW(), INTERVAL ? DAY) 42 - AND timestamp <= DATE_SUB(NOW(), INTERVAL ? DAY) 43 - ORDER BY timestamp DESC 44 - ` 45 - // Note: Perl logic was: start_days <= timestamp AND end_days >= timestamp 46 - // But start_days is the LARGER number (further back in time). 47 - // So timestamp >= (NOW - start) AND timestamp <= (NOW - end) 48 - 49 - slog.Debug("GetRecentIRCLinks", "query", query, "startDays", startDays, "endDays", endDays) 50 - rows, err := s.db.QueryContext(ctx, query, startDays, endDays) 51 - if err != nil { 52 - return nil, err 53 - } 54 - defer rows.Close() 55 - 56 - var links []IRCLink 57 - for rows.Next() { 58 - var l IRCLink 59 - var contentType sql.NullString // Handle nullable 60 - if err := rows.Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType); err != nil { 61 - return nil, err 62 - } 63 - if contentType.Valid { 64 - l.ContentType = contentType.String 65 - } 66 - links = append(links, l) 67 - } 68 - return links, nil 69 - } 70 - 71 - func (s *MySQLStore) GetRecentImages(ctx context.Context, startDays int, endDays int) ([]Image, error) { 72 - query := ` 73 - SELECT imageID, timestamp, title, link, url, md5sum 74 - FROM image 75 - WHERE timestamp >= DATE_SUB(NOW(), INTERVAL ? DAY) 76 - AND timestamp <= DATE_SUB(NOW(), INTERVAL ? DAY) 77 - ORDER BY timestamp DESC 78 - ` 79 - slog.Debug("GetRecentImages", "query", query, "startDays", startDays, "endDays", endDays) 80 - rows, err := s.db.QueryContext(ctx, query, startDays, endDays) 81 - if err != nil { 82 - return nil, err 83 - } 84 - defer rows.Close() 85 - 86 - var images []Image 87 - for rows.Next() { 88 - var i Image 89 - if err := rows.Scan(&i.ID, &i.Timestamp, &i.Title, &i.Link, &i.URL, &i.MD5Sum); err != nil { 90 - return nil, err 91 - } 92 - images = append(images, i) 93 - } 94 - return images, nil 95 - } 96 - 97 - func (s *MySQLStore) GetRecentQuotes(ctx context.Context, startDays int, endDays int) ([]Quote, error) { 98 - query := ` 99 - SELECT quoteID, timestamp, quote, author 100 - FROM quote 101 - WHERE timestamp >= DATE_SUB(NOW(), INTERVAL ? DAY) 102 - AND timestamp <= DATE_SUB(NOW(), INTERVAL ? DAY) 103 - ORDER BY timestamp DESC 104 - ` 105 - slog.Debug("GetRecentQuotes", "query", query, "startDays", startDays, "endDays", endDays) 106 - rows, err := s.db.QueryContext(ctx, query, startDays, endDays) 107 - if err != nil { 108 - return nil, err 109 - } 110 - defer rows.Close() 111 - 112 - var quotes []Quote 113 - for rows.Next() { 114 - var q Quote 115 - if err := rows.Scan(&q.ID, &q.Timestamp, &q.Quote, &q.Author); err != nil { 116 - return nil, err 117 - } 118 - quotes = append(quotes, q) 119 - } 120 - return quotes, nil 121 - } 122 - 123 - func (s *MySQLStore) SearchIRCLinks(ctx context.Context, searchTerm string) ([]IRCLink, error) { 124 - // MySQL FULLTEXT search 125 - query := ` 126 - SELECT ircLinkID, timestamp, user, title, url, clicks, content_type 127 - FROM ircLink 128 - WHERE MATCH(title, url) AGAINST(? IN BOOLEAN MODE) 129 - ORDER BY clicks DESC 130 - LIMIT 50 131 - ` 132 - slog.Debug("SearchIRCLinks", "query", query, "searchTerm", searchTerm) 133 - rows, err := s.db.QueryContext(ctx, query, searchTerm) 134 - if err != nil { 135 - return nil, err 136 - } 137 - defer rows.Close() 138 - 139 - var links []IRCLink 140 - for rows.Next() { 141 - var l IRCLink 142 - var contentType sql.NullString 143 - if err := rows.Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType); err != nil { 144 - return nil, err 145 - } 146 - if contentType.Valid { 147 - l.ContentType = contentType.String 148 - } 149 - links = append(links, l) 150 - } 151 - return links, nil 152 - } 153 - 154 - func (s *MySQLStore) GetTopIRCLinks(ctx context.Context, startDays int, endDays int, limit int) ([]IRCLink, error) { 155 - query := ` 156 - SELECT ircLinkID, timestamp, user, title, url, clicks, content_type 157 - FROM ircLink 158 - WHERE timestamp >= DATE_SUB(NOW(), INTERVAL ? DAY) 159 - AND timestamp <= DATE_SUB(NOW(), INTERVAL ? DAY) 160 - AND clicks > 1 161 - ORDER BY clicks DESC 162 - LIMIT ? 163 - ` 164 - rows, err := s.db.QueryContext(ctx, query, startDays, endDays, limit) 165 - if err != nil { 166 - return nil, err 167 - } 168 - defer rows.Close() 169 - 170 - var links []IRCLink 171 - for rows.Next() { 172 - var l IRCLink 173 - var contentType sql.NullString 174 - if err := rows.Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType); err != nil { 175 - return nil, err 176 - } 177 - if contentType.Valid { 178 - l.ContentType = contentType.String 179 - } 180 - links = append(links, l) 181 - } 182 - return links, nil 183 - } 184 - 185 - func (s *MySQLStore) GetIRCLinkByID(ctx context.Context, id int) (*IRCLink, error) { 186 - query := ` 187 - SELECT ircLinkID, timestamp, user, title, url, clicks, content_type 188 - FROM ircLink 189 - WHERE ircLinkID = ? 190 - ` 191 - var l IRCLink 192 - var contentType sql.NullString 193 - err := s.db.QueryRowContext(ctx, query, id).Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType) 194 - if err != nil { 195 - if err == sql.ErrNoRows { 196 - return nil, nil // Or error? 197 - } 198 - return nil, err 199 - } 200 - if contentType.Valid { 201 - l.ContentType = contentType.String 202 - } 203 - return &l, nil 204 - } 205 - 206 - func (s *MySQLStore) GetIRCLinkURL(ctx context.Context, id int) (string, error) { 207 - query := `SELECT url FROM ircLink WHERE ircLinkID = ?` 208 - var url string 209 - err := s.db.QueryRowContext(ctx, query, id).Scan(&url) 210 - if err != nil { 211 - return "", err 212 - } 213 - return url, nil 214 - } 215 - 216 - func (s *MySQLStore) IncrementClicks(ctx context.Context, id int) error { 217 - query := `UPDATE ircLink SET timestamp = timestamp, clicks = clicks + 1 WHERE ircLinkID = ?` 218 - _, err := s.db.ExecContext(ctx, query, id) 219 - return err 220 - } 221 - 222 - func (s *MySQLStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) { 223 - query := `INSERT INTO ircLink (user, title, url, content_type, clicks) VALUES (?, ?, ?, ?, 0)` 224 - res, err := s.db.ExecContext(ctx, query, user, title, url, contentType) 225 - if err != nil { 226 - return 0, err 227 - } 228 - id, err := res.LastInsertId() 229 - return int(id), err 230 - } 231 - 232 - func (s *MySQLStore) InsertQuote(ctx context.Context, quote, author string) error { 233 - query := `INSERT INTO quote (quote, author) VALUES (?, ?)` 234 - _, err := s.db.ExecContext(ctx, query, quote, author) 235 - return err 236 - } 237 - 238 - func (s *MySQLStore) GetRandomQuote(ctx context.Context) (*Quote, error) { 239 - query := `SELECT quoteID, timestamp, quote, author FROM quote ORDER BY RAND() LIMIT 1` 240 - row := s.db.QueryRowContext(ctx, query) 241 - 242 - var q Quote 243 - if err := row.Scan(&q.ID, &q.Timestamp, &q.Quote, &q.Author); err != nil { 244 - return nil, err 245 - } 246 - return &q, nil 247 - } 248 - 249 - func (s *MySQLStore) GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]UserStat, error) { 250 - // Sort logic 251 - orderBy := "link_count DESC" 252 - switch sortBy { 253 - case "user": 254 - orderBy = "u.user ASC" 255 - case "quotes": 256 - orderBy = "quote_count DESC" 257 - case "links": 258 - orderBy = "link_count DESC" 259 - } 260 - 261 - query := fmt.Sprintf(` 262 - SELECT 263 - u.user, 264 - COALESCE(l.count, 0) as link_count, 265 - COALESCE(q.count, 0) as quote_count 266 - FROM 267 - (SELECT DISTINCT user FROM ircLink UNION SELECT DISTINCT author as user FROM quote) u 268 - LEFT JOIN 269 - (SELECT user, COUNT(*) as count FROM ircLink GROUP BY user) l ON u.user = l.user 270 - LEFT JOIN 271 - (SELECT author, COUNT(*) as count FROM quote GROUP BY author) q ON u.user = q.author 272 - ORDER BY %s 273 - LIMIT ? OFFSET ? 274 - `, orderBy) 275 - 276 - rows, err := s.db.QueryContext(ctx, query, limit, offset) 277 - if err != nil { 278 - return nil, err 279 - } 280 - defer rows.Close() 281 - 282 - var stats []UserStat 283 - for rows.Next() { 284 - var stat UserStat 285 - if err := rows.Scan(&stat.User, &stat.LinkCount, &stat.QuoteCount); err != nil { 286 - return nil, err 287 - } 288 - stats = append(stats, stat) 289 - } 290 - return stats, nil 291 - } 292 - 293 - func (s *MySQLStore) GetUserTimeline(ctx context.Context, user string, filterType string, limit int, offset int) ([]TimelineItem, error) { 294 - var query string 295 - var args []interface{} 296 - 297 - linkQuery := `SELECT 'link', ircLinkID, timestamp, title, url, '' FROM ircLink WHERE user = ?` 298 - quoteQuery := `SELECT 'quote', quoteID, timestamp, '', '', quote FROM quote WHERE author = ?` 299 - 300 - if filterType == "links" { 301 - query = linkQuery 302 - args = append(args, user) 303 - } else if filterType == "quotes" { 304 - query = quoteQuery 305 - args = append(args, user) 306 - } else { 307 - // Union 308 - query = linkQuery + " UNION ALL " + quoteQuery 309 - args = append(args, user, user) 310 - } 311 - 312 - query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?" 313 - args = append(args, limit, offset) 314 - 315 - rows, err := s.db.QueryContext(ctx, query, args...) 316 - if err != nil { 317 - return nil, err 318 - } 319 - defer rows.Close() 320 - 321 - var items []TimelineItem 322 - for rows.Next() { 323 - var i TimelineItem 324 - // Scan matches SELECT order: Type, ID, Timestamp, Title, URL, Content 325 - if err := rows.Scan(&i.Type, &i.ID, &i.Timestamp, &i.Title, &i.URL, &i.Content); err != nil { 326 - return nil, err 327 - } 328 - items = append(items, i) 329 - } 330 - return items, nil 331 - } 332 - 333 - func (s *MySQLStore) GetLinksByUser(ctx context.Context, user string, limit int, offset int) ([]IRCLink, error) { 334 - query := ` 335 - SELECT ircLinkID, timestamp, user, title, url, clicks, content_type 336 - FROM ircLink 337 - WHERE user = ? 338 - ORDER BY timestamp DESC 339 - LIMIT ? OFFSET ? 340 - ` 341 - rows, err := s.db.QueryContext(ctx, query, user, limit, offset) 342 - if err != nil { 343 - return nil, err 344 - } 345 - defer rows.Close() 346 - 347 - var links []IRCLink 348 - for rows.Next() { 349 - var l IRCLink 350 - var contentType sql.NullString 351 - if err := rows.Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType); err != nil { 352 - return nil, err 353 - } 354 - if contentType.Valid { 355 - l.ContentType = contentType.String 356 - } 357 - links = append(links, l) 358 - } 359 - return links, nil 360 - } 361 - 362 - func (s *MySQLStore) GetGlobalTimeline(ctx context.Context, limit int, offset int) ([]TimelineItem, error) { 363 - query := ` 364 - SELECT 365 - 'link' as type, ircLinkID as id, timestamp, title, url, '' as content, user as author, '' as md5sum 366 - FROM ircLink 367 - UNION ALL 368 - SELECT 369 - 'quote' as type, quoteID as id, timestamp, '' as title, '' as url, quote as content, author as author, '' as md5sum 370 - FROM quote 371 - UNION ALL 372 - SELECT 373 - 'image' as type, imageID as id, timestamp, title, url, '' as content, '' as author, md5sum 374 - FROM image 375 - ORDER BY timestamp DESC 376 - LIMIT ? OFFSET ? 377 - ` 378 - rows, err := s.db.QueryContext(ctx, query, limit, offset) 379 - if err != nil { 380 - return nil, err 381 - } 382 - defer rows.Close() 383 - 384 - var items []TimelineItem 385 - for rows.Next() { 386 - var i TimelineItem 387 - // Scan matches SELECT order: Type, ID, Timestamp, Title, URL, Content, Author, MD5Sum 388 - if err := rows.Scan(&i.Type, &i.ID, &i.Timestamp, &i.Title, &i.URL, &i.Content, &i.Author, &i.MD5Sum); err != nil { 389 - return nil, err 390 - } 391 - items = append(items, i) 392 - } 393 - return items, nil 394 - } 395 - 396 - func (s *MySQLStore) Bootstrap(ctx context.Context) error { 397 - // User databaseName "tumble" or extract from config? 398 - // DSN parsing is complex. For now we assume "mysql" driver name is sufficient. 399 - // Actually, RunMigrations needs databaseName to keep migrate logic happy implicitly? 400 - // The driverName check we wrote just uses "mysql". 401 - return RunMigrations(s.db, "mysql", "mysql") 402 - }
-395
internal/data/sqlite.go
··· 1 - package data 2 - 3 - import ( 4 - "context" 5 - "database/sql" 6 - "fmt" 7 - "log/slog" 8 - 9 - _ "modernc.org/sqlite" 10 - ) 11 - 12 - type SQLiteStore struct { 13 - db *sql.DB 14 - } 15 - 16 - func NewSQLiteStore(dsn string) (*SQLiteStore, error) { 17 - db, err := sql.Open("sqlite", dsn) 18 - if err != nil { 19 - return nil, err 20 - } 21 - if err := db.Ping(); err != nil { 22 - return nil, err 23 - } 24 - return &SQLiteStore{db: db}, nil 25 - } 26 - 27 - func (s *SQLiteStore) Close() error { 28 - return s.db.Close() 29 - } 30 - 31 - func (s *SQLiteStore) GetRecentIRCLinks(ctx context.Context, startDays int, endDays int) ([]IRCLink, error) { 32 - // timestamp >= datetime('now', '-' || ? || ' days') 33 - query := ` 34 - SELECT ircLinkID, timestamp, user, title, url, clicks, content_type 35 - FROM ircLink 36 - WHERE timestamp >= datetime('now', '-' || ? || ' days') 37 - AND timestamp <= datetime('now', '-' || ? || ' days') 38 - ORDER BY timestamp DESC 39 - ` 40 - slog.Debug("GetRecentIRCLinks", "query", query, "startDays", startDays, "endDays", endDays) 41 - rows, err := s.db.QueryContext(ctx, query, startDays, endDays) 42 - if err != nil { 43 - return nil, err 44 - } 45 - defer rows.Close() 46 - 47 - var links []IRCLink 48 - 49 - for rows.Next() { 50 - var l IRCLink 51 - var contentType sql.NullString 52 - if err := rows.Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType); err != nil { 53 - return nil, err 54 - } 55 - if contentType.Valid { 56 - l.ContentType = contentType.String 57 - } 58 - links = append(links, l) 59 - } 60 - return links, nil 61 - } 62 - 63 - func (s *SQLiteStore) GetRecentImages(ctx context.Context, startDays int, endDays int) ([]Image, error) { 64 - query := ` 65 - SELECT imageID, timestamp, title, link, url, md5sum 66 - FROM image 67 - WHERE timestamp >= datetime('now', '-' || ? || ' days') 68 - AND timestamp <= datetime('now', '-' || ? || ' days') 69 - ORDER BY timestamp DESC 70 - ` 71 - slog.Debug("GetRecentImages", "query", query, "startDays", startDays, "endDays", endDays) 72 - rows, err := s.db.QueryContext(ctx, query, startDays, endDays) 73 - if err != nil { 74 - return nil, err 75 - } 76 - defer rows.Close() 77 - 78 - var images []Image 79 - for rows.Next() { 80 - var i Image 81 - if err := rows.Scan(&i.ID, &i.Timestamp, &i.Title, &i.Link, &i.URL, &i.MD5Sum); err != nil { 82 - return nil, err 83 - } 84 - images = append(images, i) 85 - } 86 - return images, nil 87 - } 88 - 89 - func (s *SQLiteStore) GetRecentQuotes(ctx context.Context, startDays int, endDays int) ([]Quote, error) { 90 - query := ` 91 - SELECT quoteID, timestamp, quote, author 92 - FROM quote 93 - WHERE timestamp >= datetime('now', '-' || ? || ' days') 94 - AND timestamp <= datetime('now', '-' || ? || ' days') 95 - ORDER BY timestamp DESC 96 - ` 97 - slog.Debug("GetRecentQuotes", "query", query, "startDays", startDays, "endDays", endDays) 98 - rows, err := s.db.QueryContext(ctx, query, startDays, endDays) 99 - if err != nil { 100 - return nil, err 101 - } 102 - defer rows.Close() 103 - 104 - var quotes []Quote 105 - for rows.Next() { 106 - var q Quote 107 - if err := rows.Scan(&q.ID, &q.Timestamp, &q.Quote, &q.Author); err != nil { 108 - return nil, err 109 - } 110 - quotes = append(quotes, q) 111 - } 112 - return quotes, nil 113 - } 114 - 115 - func (s *SQLiteStore) SearchIRCLinks(ctx context.Context, searchTerm string) ([]IRCLink, error) { 116 - // SQLite LIKE-based search (Perl compat) 117 - query := ` 118 - SELECT ircLinkID, timestamp, user, title, url, clicks, content_type 119 - FROM ircLink 120 - WHERE title LIKE ? OR url LIKE ? 121 - ORDER BY clicks DESC 122 - LIMIT 50 123 - ` 124 - likeTerm := fmt.Sprintf("%%%s%%", searchTerm) 125 - slog.Debug("SearchIRCLinks", "query", query, "searchTerm", searchTerm) 126 - rows, err := s.db.QueryContext(ctx, query, likeTerm, likeTerm) 127 - if err != nil { 128 - return nil, err 129 - } 130 - defer rows.Close() 131 - 132 - var links []IRCLink 133 - for rows.Next() { 134 - var l IRCLink 135 - var contentType sql.NullString 136 - if err := rows.Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType); err != nil { 137 - return nil, err 138 - } 139 - if contentType.Valid { 140 - l.ContentType = contentType.String 141 - } 142 - links = append(links, l) 143 - } 144 - return links, nil 145 - } 146 - 147 - func (s *SQLiteStore) GetTopIRCLinks(ctx context.Context, startDays int, endDays int, limit int) ([]IRCLink, error) { 148 - query := ` 149 - SELECT ircLinkID, timestamp, user, title, url, clicks, content_type 150 - FROM ircLink 151 - WHERE timestamp >= datetime('now', '-' || ? || ' days') 152 - AND timestamp <= datetime('now', '-' || ? || ' days') 153 - AND clicks > 1 154 - ORDER BY clicks DESC 155 - LIMIT ? 156 - ` 157 - rows, err := s.db.QueryContext(ctx, query, startDays, endDays, limit) 158 - if err != nil { 159 - return nil, err 160 - } 161 - defer rows.Close() 162 - 163 - var links []IRCLink 164 - for rows.Next() { 165 - var l IRCLink 166 - var contentType sql.NullString 167 - if err := rows.Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType); err != nil { 168 - return nil, err 169 - } 170 - if contentType.Valid { 171 - l.ContentType = contentType.String 172 - } 173 - links = append(links, l) 174 - } 175 - return links, nil 176 - } 177 - 178 - func (s *SQLiteStore) GetIRCLinkByID(ctx context.Context, id int) (*IRCLink, error) { 179 - query := ` 180 - SELECT ircLinkID, timestamp, user, title, url, clicks, content_type 181 - FROM ircLink 182 - WHERE ircLinkID = ? 183 - ` 184 - var l IRCLink 185 - var contentType sql.NullString 186 - err := s.db.QueryRowContext(ctx, query, id).Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType) 187 - if err != nil { 188 - if err == sql.ErrNoRows { 189 - return nil, nil 190 - } 191 - return nil, err 192 - } 193 - if contentType.Valid { 194 - l.ContentType = contentType.String 195 - } 196 - return &l, nil 197 - } 198 - 199 - func (s *SQLiteStore) GetIRCLinkURL(ctx context.Context, id int) (string, error) { 200 - query := `SELECT url FROM ircLink WHERE ircLinkID = ?` 201 - var url string 202 - err := s.db.QueryRowContext(ctx, query, id).Scan(&url) 203 - if err != nil { 204 - return "", err 205 - } 206 - return url, nil 207 - } 208 - 209 - func (s *SQLiteStore) IncrementClicks(ctx context.Context, id int) error { 210 - // timestamp hack to match MySQL behavior if needed, but SQLite defaults current_timestamp on update usually triggers only if trigger exists? 211 - // The schema said default current timestamp. 212 - // Updating timestamp explicitly: 213 - query := `UPDATE ircLink SET timestamp = datetime('now'), clicks = clicks + 1 WHERE ircLinkID = ?` 214 - _, err := s.db.ExecContext(ctx, query, id) 215 - return err 216 - } 217 - 218 - func (s *SQLiteStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) { 219 - query := `INSERT INTO ircLink (user, title, url, content_type, clicks) VALUES (?, ?, ?, ?, 0)` 220 - res, err := s.db.ExecContext(ctx, query, user, title, url, contentType) 221 - if err != nil { 222 - return 0, err 223 - } 224 - id, err := res.LastInsertId() 225 - return int(id), err 226 - } 227 - 228 - func (s *SQLiteStore) InsertQuote(ctx context.Context, quote, author string) error { 229 - query := `INSERT INTO quote (quote, author) VALUES (?, ?)` 230 - _, err := s.db.ExecContext(ctx, query, quote, author) 231 - return err 232 - } 233 - 234 - func (s *SQLiteStore) GetRandomQuote(ctx context.Context) (*Quote, error) { 235 - query := `SELECT quoteID, timestamp, quote, author FROM quote ORDER BY RANDOM() LIMIT 1` 236 - row := s.db.QueryRowContext(ctx, query) 237 - 238 - var q Quote 239 - if err := row.Scan(&q.ID, &q.Timestamp, &q.Quote, &q.Author); err != nil { 240 - return nil, err 241 - } 242 - return &q, nil 243 - } 244 - 245 - func (s *SQLiteStore) GetUserStats(ctx context.Context, sortBy string, limit int, offset int) ([]UserStat, error) { 246 - // Sort logic 247 - orderBy := "link_count DESC" 248 - switch sortBy { 249 - case "user": 250 - orderBy = "u.user ASC" 251 - case "quotes": 252 - orderBy = "quote_count DESC" 253 - case "links": 254 - orderBy = "link_count DESC" 255 - } 256 - 257 - query := fmt.Sprintf(` 258 - SELECT 259 - u.user, 260 - COALESCE(l.count, 0) as link_count, 261 - COALESCE(q.count, 0) as quote_count 262 - FROM 263 - (SELECT DISTINCT user FROM ircLink UNION SELECT DISTINCT author as user FROM quote) u 264 - LEFT JOIN 265 - (SELECT user, COUNT(*) as count FROM ircLink GROUP BY user) l ON u.user = l.user 266 - LEFT JOIN 267 - (SELECT author, COUNT(*) as count FROM quote GROUP BY author) q ON u.user = q.author 268 - ORDER BY %s 269 - LIMIT ? OFFSET ? 270 - `, orderBy) 271 - 272 - rows, err := s.db.QueryContext(ctx, query, limit, offset) 273 - if err != nil { 274 - return nil, err 275 - } 276 - defer rows.Close() 277 - 278 - var stats []UserStat 279 - for rows.Next() { 280 - var stat UserStat 281 - if err := rows.Scan(&stat.User, &stat.LinkCount, &stat.QuoteCount); err != nil { 282 - return nil, err 283 - } 284 - stats = append(stats, stat) 285 - } 286 - return stats, nil 287 - } 288 - 289 - func (s *SQLiteStore) GetUserTimeline(ctx context.Context, user string, filterType string, limit int, offset int) ([]TimelineItem, error) { 290 - var query string 291 - var args []interface{} 292 - 293 - linkQuery := `SELECT 'link', ircLinkID, timestamp, title, url, '' FROM ircLink WHERE user = ?` 294 - quoteQuery := `SELECT 'quote', quoteID, timestamp, '', '', quote FROM quote WHERE author = ?` 295 - 296 - if filterType == "links" { 297 - query = linkQuery 298 - args = append(args, user) 299 - } else if filterType == "quotes" { 300 - query = quoteQuery 301 - args = append(args, user) 302 - } else { 303 - // Union 304 - query = linkQuery + " UNION ALL " + quoteQuery 305 - args = append(args, user, user) 306 - } 307 - 308 - query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?" 309 - args = append(args, limit, offset) 310 - 311 - rows, err := s.db.QueryContext(ctx, query, args...) 312 - if err != nil { 313 - return nil, err 314 - } 315 - defer rows.Close() 316 - 317 - var items []TimelineItem 318 - for rows.Next() { 319 - var i TimelineItem 320 - // Scan matches SELECT order: Type, ID, Timestamp, Title, URL, Content 321 - if err := rows.Scan(&i.Type, &i.ID, &i.Timestamp, &i.Title, &i.URL, &i.Content); err != nil { 322 - return nil, err 323 - } 324 - items = append(items, i) 325 - } 326 - return items, nil 327 - } 328 - 329 - func (s *SQLiteStore) GetLinksByUser(ctx context.Context, user string, limit int, offset int) ([]IRCLink, error) { 330 - query := ` 331 - SELECT ircLinkID, timestamp, user, title, url, clicks, content_type 332 - FROM ircLink 333 - WHERE user = ? 334 - ORDER BY timestamp DESC 335 - LIMIT ? OFFSET ? 336 - ` 337 - rows, err := s.db.QueryContext(ctx, query, user, limit, offset) 338 - if err != nil { 339 - return nil, err 340 - } 341 - defer rows.Close() 342 - 343 - var links []IRCLink 344 - for rows.Next() { 345 - var l IRCLink 346 - var contentType sql.NullString 347 - if err := rows.Scan(&l.ID, &l.Timestamp, &l.User, &l.Title, &l.URL, &l.Clicks, &contentType); err != nil { 348 - return nil, err 349 - } 350 - if contentType.Valid { 351 - l.ContentType = contentType.String 352 - } 353 - links = append(links, l) 354 - } 355 - return links, nil 356 - } 357 - 358 - func (s *SQLiteStore) GetGlobalTimeline(ctx context.Context, limit int, offset int) ([]TimelineItem, error) { 359 - // SQLite queries for literal strings sometimes need quotes or casting. 360 - query := ` 361 - SELECT 362 - 'link' as type, ircLinkID as id, timestamp, title, url, '' as content, user as author, '' as md5sum 363 - FROM ircLink 364 - UNION ALL 365 - SELECT 366 - 'quote' as type, quoteID as id, timestamp, '' as title, '' as url, quote as content, author as author, '' as md5sum 367 - FROM quote 368 - UNION ALL 369 - SELECT 370 - 'image' as type, imageID as id, timestamp, title, url, '' as content, '' as author, md5sum 371 - FROM image 372 - ORDER BY timestamp DESC 373 - LIMIT ? OFFSET ? 374 - ` 375 - rows, err := s.db.QueryContext(ctx, query, limit, offset) 376 - if err != nil { 377 - return nil, err 378 - } 379 - defer rows.Close() 380 - 381 - var items []TimelineItem 382 - for rows.Next() { 383 - var i TimelineItem 384 - // Scan matches SELECT order: Type, ID, Timestamp, Title, URL, Content, Author, MD5Sum 385 - if err := rows.Scan(&i.Type, &i.ID, &i.Timestamp, &i.Title, &i.URL, &i.Content, &i.Author, &i.MD5Sum); err != nil { 386 - return nil, err 387 - } 388 - items = append(items, i) 389 - } 390 - return items, nil 391 - } 392 - 393 - func (s *SQLiteStore) Bootstrap(ctx context.Context) error { 394 - return RunMigrations(s.db, "sqlite", "sqlite") 395 - }
-137
internal/data/sqlite_driver.go
··· 1 - package data 2 - 3 - import ( 4 - "database/sql" 5 - "fmt" 6 - "io" 7 - "io/ioutil" 8 - "strings" 9 - 10 - migrate "github.com/golang-migrate/migrate/v4/database" 11 - ) 12 - 13 - func init() { 14 - // We don't register automatically to avoid importing this package everywhere 15 - } 16 - 17 - type Config struct { 18 - MigrationsTable string 19 - NoTxWrap bool 20 - } 21 - 22 - type SQLite struct { 23 - db *sql.DB 24 - config *Config 25 - } 26 - 27 - func WithInstance(instance *sql.DB, config *Config) (migrate.Driver, error) { 28 - if config == nil { 29 - config = &Config{} 30 - } 31 - if config.MigrationsTable == "" { 32 - config.MigrationsTable = "schema_migrations" 33 - } 34 - return &SQLite{ 35 - db: instance, 36 - config: config, 37 - }, nil 38 - } 39 - 40 - func (s *SQLite) Open(url string) (migrate.Driver, error) { 41 - return nil, fmt.Errorf("not implemented, use WithInstance") 42 - } 43 - 44 - func (s *SQLite) Close() error { 45 - // We don't close the DB here as it's passed in instance 46 - return nil 47 - } 48 - 49 - func (s *SQLite) Lock() error { 50 - return nil // SQLite doesn't need explicit locking usually if single threaded migration or handled by db lock 51 - } 52 - 53 - func (s *SQLite) Unlock() error { 54 - return nil 55 - } 56 - 57 - func (s *SQLite) Run(migration io.Reader) error { 58 - migr, err := ioutil.ReadAll(migration) 59 - if err != nil { 60 - return err 61 - } 62 - query := string(migr) 63 - if strings.TrimSpace(query) == "" { 64 - return nil 65 - } 66 - 67 - if s.config.NoTxWrap { 68 - _, err := s.db.Exec(query) 69 - return err 70 - } 71 - 72 - tx, err := s.db.Begin() 73 - if err != nil { 74 - return err 75 - } 76 - if _, err := tx.Exec(query); err != nil { 77 - tx.Rollback() 78 - return err 79 - } 80 - return tx.Commit() 81 - } 82 - 83 - func (s *SQLite) SetVersion(version int, dirty bool) error { 84 - tx, err := s.db.Begin() 85 - if err != nil { 86 - return err 87 - } 88 - // Delete all 89 - if _, err := tx.Exec(fmt.Sprintf("DELETE FROM %s", s.config.MigrationsTable)); err != nil { 90 - tx.Rollback() 91 - return err 92 - } 93 - // Insert new 94 - if _, err := tx.Exec(fmt.Sprintf("INSERT INTO %s (version, dirty) VALUES (?, ?)", s.config.MigrationsTable), version, dirty); err != nil { 95 - tx.Rollback() 96 - return err 97 - } 98 - return tx.Commit() 99 - } 100 - 101 - func (s *SQLite) Version() (version int, dirty bool, err error) { 102 - query := fmt.Sprintf("SELECT version, dirty FROM %s LIMIT 1", s.config.MigrationsTable) 103 - err = s.db.QueryRow(query).Scan(&version, &dirty) 104 - if err != nil { 105 - if err == sql.ErrNoRows { 106 - return migrate.NilVersion, false, nil 107 - } 108 - // If table doesn't exist, create it and return nil version 109 - // simple check: try creating 110 - if err := s.ensureVersionTable(); err != nil { 111 - return 0, false, err 112 - } 113 - // Retry 114 - err = s.db.QueryRow(query).Scan(&version, &dirty) 115 - if err == sql.ErrNoRows { 116 - return migrate.NilVersion, false, nil 117 - } 118 - return version, dirty, err 119 - } 120 - return version, dirty, nil 121 - } 122 - 123 - func (s *SQLite) Drop() error { 124 - // Not implemented 125 - return nil 126 - } 127 - 128 - func (s *SQLite) ensureVersionTable() error { 129 - query := fmt.Sprintf(` 130 - CREATE TABLE IF NOT EXISTS %s ( 131 - version INTEGER PRIMARY KEY, 132 - dirty BOOLEAN NOT NULL 133 - ) 134 - `, s.config.MigrationsTable) 135 - _, err := s.db.Exec(query) 136 - return err 137 - }
+32 -17
internal/data/store.go
··· 6 6 ) 7 7 8 8 type IRCLink struct { 9 - ID int `json:"ircLinkID"` 10 - Timestamp time.Time `json:"timestamp"` 11 - User string `json:"user"` 12 - Title string `json:"title"` 13 - URL string `json:"url"` 14 - Clicks int `json:"clicks"` 15 - ContentType string `json:"content_type"` 9 + ID int `json:"ircLinkID" gorm:"column:ircLinkID;primaryKey"` 10 + Timestamp time.Time `json:"timestamp" gorm:"column:timestamp"` 11 + User string `json:"user" gorm:"column:user"` 12 + Title string `json:"title" gorm:"column:title"` 13 + URL string `json:"url" gorm:"column:url"` 14 + Clicks int `json:"clicks" gorm:"column:clicks;default:0"` 15 + ContentType string `json:"content_type" gorm:"column:content_type"` 16 + } 17 + 18 + // TableName overrides the table name used by User to `ircLink` 19 + func (IRCLink) TableName() string { 20 + return "ircLink" 16 21 } 17 22 18 23 type Image struct { 19 - ID int `json:"imageID"` 20 - Timestamp time.Time `json:"timestamp"` 21 - Title string `json:"title"` 22 - Link string `json:"link"` 23 - URL string `json:"url"` 24 - MD5Sum string `json:"md5sum"` 24 + ID int `json:"imageID" gorm:"column:imageID;primaryKey"` 25 + Timestamp time.Time `json:"timestamp" gorm:"column:timestamp"` 26 + Title string `json:"title" gorm:"column:title"` 27 + Link string `json:"link" gorm:"column:link"` 28 + URL string `json:"url" gorm:"column:url"` 29 + MD5Sum string `json:"md5sum" gorm:"column:md5sum"` 30 + } 31 + 32 + // TableName overrides the table name used by User to `image` 33 + func (Image) TableName() string { 34 + return "image" 25 35 } 26 36 27 37 type Quote struct { 28 - ID int `json:"quoteID"` 29 - Timestamp time.Time `json:"timestamp"` 30 - Quote string `json:"quote"` 31 - Author string `json:"author"` 38 + ID int `json:"quoteID" gorm:"column:quoteID;primaryKey"` 39 + Timestamp time.Time `json:"timestamp" gorm:"column:timestamp"` 40 + Quote string `json:"quote" gorm:"column:quote"` 41 + Author string `json:"author" gorm:"column:author"` 42 + } 43 + 44 + // TableName overrides the table name used by User to `quote` 45 + func (Quote) TableName() string { 46 + return "quote" 32 47 } 33 48 34 49 type UserStat struct {