Compare commits

..

75 Commits

Author SHA1 Message Date
filipriec
ed786f087c changing test for a huge change in a project 2025-06-20 20:07:07 +02:00
filipriec
8e22ea05ff improvements and fixing of the tests 2025-06-20 19:59:42 +02:00
filipriec
8414657224 gen isolated tables 2025-06-18 23:19:19 +02:00
filipriec
e25213ed1b tests are robusts running in parallel 2025-06-18 22:38:00 +02:00
filipriec
4843b0778c robust testing of the table definitions 2025-06-18 21:37:30 +02:00
filipriec
f5fae98c69 tests now working via make file 2025-06-18 14:44:38 +02:00
filipriec
6faf0a4a31 tests for table definitions 2025-06-17 22:46:04 +02:00
filipriec
011fafc0ff now working proper types 2025-06-17 17:31:11 +02:00
filipriec
8ebe74484c now not creating tables with the year_ prefix and living in the gen schema by default 2025-06-17 11:45:55 +02:00
filipriec
3eb9523103 you are going to kill me but please dont, i just cleaned up migration file and its 100% valid, do not use any version before this version and after this version so many things needs to be changed so haha... im ashamed but i love it at the same time 2025-06-17 11:21:33 +02:00
filipriec
3dfa922b9e unimportant change 2025-06-17 10:27:22 +02:00
filipriec
248d54a30f accpeting now null in the post table data as nothing 2025-06-16 22:51:05 +02:00
filipriec
b30fef4ccd post doesnt work, but refactored code displays the autocomplete at least, needs fix 2025-06-16 16:42:25 +02:00
filipriec
a9c4527318 complete redesign oh how client is displaying data 2025-06-16 16:10:24 +02:00
filipriec
c31f08d5b8 fixing post with links 2025-06-16 14:42:49 +02:00
filipriec
9e0fa9ddb1 autocomplete now autocompleting data not just id 2025-06-16 11:54:54 +02:00
filipriec
8fcd28832d better answer parsing 2025-06-16 11:14:04 +02:00
filipriec
cccf029464 autocomplete is now perfectc 2025-06-16 10:52:28 +02:00
filipriec
512e7fb9e7 suggestions in the dropdown menu now works amazingly well 2025-06-15 23:11:27 +02:00
filipriec
0e69df8282 empty search is now allowed 2025-06-15 18:36:01 +02:00
filipriec
eb5532c200 finally works as i wanted it to 2025-06-15 14:23:19 +02:00
filipriec
49ed1dfe33 trash 2025-06-15 13:52:43 +02:00
filipriec
62d1c3f7f5 suggestion works, but not exactly, needs more stuff 2025-06-15 13:35:45 +02:00
filipriec
b49dce3334 dropdown is being triggered 2025-06-15 12:15:25 +02:00
filipriec
8ace9bc4d1 links are now in the get method of the backend 2025-06-14 18:09:30 +02:00
filipriec
ce490007ed fixing server responses, now push data links fixed 2025-06-14 17:39:59 +02:00
filipriec
eb96c64e26 links to the other tables 2025-06-14 12:47:59 +02:00
filipriec
2ac96a8486 working perfectly well with the search and debug in the status line when enabled 2025-06-13 20:46:33 +02:00
filipriec
b8e6cc22af way better debugging in the status line now 2025-06-13 16:57:58 +02:00
filipriec
634a01f618 service search changed 2025-06-13 16:53:39 +02:00
filipriec
6abea062ba ui debug in status line 2025-06-13 15:26:45 +02:00
filipriec
f50887a326 outputting to the status line 2025-06-13 13:38:40 +02:00
filipriec
3c0af05a3c the search tui is not working yet 2025-06-11 22:08:23 +02:00
filipriec
c9131d4457 working but not properly displaying search results 2025-06-11 16:46:55 +02:00
filipriec
2af79a3ef2 search added, but unable to trigger it yet 2025-06-11 16:24:42 +02:00
filipriec
afd9228efa json in the otput of the tantivy 2025-06-11 14:07:22 +02:00
filipriec
495d77fda5 4 ngram tokenizer, not doing anything elsekeeping this as is 2025-06-10 23:56:31 +02:00
filipriec
679bb3b6ab search in common module, now fixing layer mixing issue 2025-06-10 13:47:18 +02:00
filipriec
350c522d19 better search but still has some flaws. It at least works, even tho its not perfect. Needs more testing, but im pretty happy with it rn, keeping it this way 2025-06-10 00:22:31 +02:00
filipriec
4760f42589 slovak language tokenized search 2025-06-09 16:36:18 +02:00
filipriec
50d15e321f automatic indexing is working perfectly well 2025-06-08 23:26:13 +02:00
filipriec
a3e7fd8f0a forgotten changes to the lib that are needed for a single port of two crates working separately 2025-06-08 22:40:46 +02:00
filipriec
645172747a we are now running search server at the same port as the whole backend service 2025-06-08 21:53:48 +02:00
filipriec
7c4ac1eebc search via tantivy on different grpc port works perfectly well now 2025-06-08 21:28:10 +02:00
filipriec
4b4301ad49 fixed now it all compiled successfuly 2025-06-08 20:14:44 +02:00
filipriec
b60e03eb70 search crate compiled, lets get to fixing all the other errors 2025-06-08 20:10:57 +02:00
filipriec
2c7bda3ff1 search crate created 2025-06-08 16:25:56 +02:00
filipriec
eeaaa3635b crucial dialog reloading bug fixed for good(hardest bug had a single line of code fix) 2025-06-08 10:53:46 +02:00
filipriec
e61cbb3956 features ui debug is now working perfectly well, it debugs the rerender flags 2025-06-08 09:26:56 +02:00
filipriec
f9841f2ef3 centralizing logic in the formstate 2025-06-08 00:00:37 +02:00
filipriec
dc232b2523 form is now working as expected 2025-06-07 15:25:35 +02:00
filipriec
b086b3e236 hardcoded firma is being removed part2 2025-06-07 15:12:00 +02:00
filipriec
387e1a0fe0 displaying data properly, fixing hardcoded backend to firma part one 2025-06-07 14:05:35 +02:00
filipriec
08e01d41f2 now properly not displaying in the frontend form fields that should be hidden from the user 2025-06-07 09:37:12 +02:00
filipriec
f5edf52571 working find palette now properly well 2025-06-07 09:16:43 +02:00
filipriec
02c62213c3 making select from the find file to work, not yet working, needs more redesign in how select is working 2025-06-06 23:44:29 +02:00
filipriec
d0722fbbbe working well now, creation of the columns 2025-06-06 20:18:51 +02:00
filipriec
4ec569342d hidden from the user now in the form 2025-06-03 18:47:14 +02:00
filipriec
9540d9ccb9 table definitions are now forbidden for user to allocated rust autoallocated table columns 2025-06-03 18:46:57 +02:00
filipriec
6b5cbe854b now working with the gen schema in the database 2025-06-02 12:39:23 +02:00
filipriec
59ed52814e compiled, needs other fixes 2025-06-02 12:08:16 +02:00
filipriec
3488ab4f6b hardcoded adresar to general form 2025-06-02 10:32:39 +02:00
filipriec
6e2fc5349b code cleanup 2025-05-31 23:02:09 +02:00
filipriec
ea88c2686d tabbing now adds / if there is nothing to tab to 2025-05-30 23:43:49 +02:00
filipriec
3df4baec92 tabbing now works perfectly well 2025-05-30 23:36:53 +02:00
filipriec
ff74e1aaa1 it works amazingly well now, we can select the table name via command line 2025-05-30 22:46:32 +02:00
filipriec
b0c865ab76 workig suggestion menu 2025-05-29 19:46:58 +02:00
filipriec
3dbc086f10 overriding overflows by using empty spaces as letters 2025-05-29 19:32:48 +02:00
filipriec
e9b4b34fb4 fixed height of the find file 2025-05-29 19:02:02 +02:00
filipriec
668eeee197 navigation in the menu but needs refactoring 2025-05-29 16:11:41 +02:00
filipriec
799d8471c9 open menu in command mode now implemented 2025-05-28 19:09:55 +02:00
filipriec
f77c16dec9 temp fix, before implementing C-x C-f 2025-05-28 15:53:33 +02:00
filipriec
45026cac6a table schema is gen now 2025-05-28 15:40:17 +02:00
filipriec
edf6ab5bca gen schema being created 2025-05-28 13:10:08 +02:00
filipriec
462b1f14e2 generated tables are now in gen schema, breaking change, needs crucial fixes NOW 2025-05-27 22:21:40 +02:00
83 changed files with 7116 additions and 1755 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
/target
.env
/tantivy_indexes
server/tantivy_indexes

468
Cargo.lock generated
View File

@@ -303,6 +303,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "bitpacking"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c1d3e2bfd8d06048a179f7b17afc3188effa10385e7b00dc65af6aae732ea92"
dependencies = [
"crunchy",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -322,6 +331,31 @@ dependencies = [
"cipher",
]
[[package]]
name = "bon"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced38439e7a86a4761f7f7d5ded5ff009135939ecb464a24452eaa4c1696af7d"
dependencies = [
"bon-macros",
"rustversion",
]
[[package]]
name = "bon-macros"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce61d2d3844c6b8d31b2353d9f66cf5e632b3e9549583fe3cac2f4f6136725e"
dependencies = [
"darling",
"ident_case",
"prettyplease",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.100",
]
[[package]]
name = "bumpalo"
version = "3.17.0"
@@ -361,9 +395,17 @@ version = "1.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362"
dependencies = [
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "census"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0"
[[package]]
name = "cfg-if"
version = "1.0.0"
@@ -407,6 +449,7 @@ dependencies = [
"dotenvy",
"lazy_static",
"prost",
"prost-types",
"ratatui",
"serde",
"serde_json",
@@ -445,7 +488,9 @@ name = "common"
version = "0.3.13"
dependencies = [
"prost",
"prost-types",
"serde",
"tantivy",
"tonic",
"tonic-build",
]
@@ -541,6 +586,15 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam"
version = "0.8.4"
@@ -622,6 +676,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929"
[[package]]
name = "crypto-common"
version = "0.1.6"
@@ -699,6 +759,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
"serde",
]
[[package]]
@@ -772,6 +833,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea8a8b81cacc08888170eef4d13b775126db426d0b348bee9d18c2c1eaf123cf"
[[package]]
name = "either"
version = "1.15.0"
@@ -825,6 +892,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fastdivide"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471"
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -884,6 +957,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs4"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8"
dependencies = [
"rustix 0.38.44",
"windows-sys 0.52.0",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
@@ -1142,6 +1225,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "htmlescape"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163"
[[package]]
name = "http"
version = "1.3.1"
@@ -1242,6 +1331,15 @@ dependencies = [
"tracing",
]
[[package]]
name = "hyperloglogplus"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "621debdf94dcac33e50475fdd76d34d5ea9c0362a834b9db08c3024696c1fbe3"
dependencies = [
"serde",
]
[[package]]
name = "iana-time-zone"
version = "0.1.63"
@@ -1520,6 +1618,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jobserver"
version = "0.1.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
dependencies = [
"getrandom 0.3.2",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.77"
@@ -1566,6 +1674,12 @@ dependencies = [
"spin",
]
[[package]]
name = "levenshtein_automata"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25"
[[package]]
name = "libc"
version = "0.2.172"
@@ -1651,6 +1765,12 @@ dependencies = [
"hashbrown 0.15.2",
]
[[package]]
name = "lz4_flex"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75761162ae2b0e580d7e7c390558127e5f01b4194debd6221fd8c207fc80e3f5"
[[package]]
name = "matchit"
version = "0.8.4"
@@ -1667,18 +1787,42 @@ dependencies = [
"digest",
]
[[package]]
name = "measure_time"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e"
dependencies = [
"log",
]
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "memmap2"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f"
dependencies = [
"libc",
]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.8"
@@ -1706,6 +1850,12 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03"
[[package]]
name = "murmurhash32"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b"
[[package]]
name = "native-tls"
version = "0.2.14"
@@ -1723,6 +1873,16 @@ dependencies = [
"tempfile",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
@@ -1857,6 +2017,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "oneshot"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ce411919553d3f9fa53a0880544cda985a112117a0444d5ff1e870a893d6ea"
[[package]]
name = "openssl"
version = "0.10.72"
@@ -1913,6 +2079,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "ownedbytes"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -2275,6 +2450,16 @@ dependencies = [
"getrandom 0.3.2",
]
[[package]]
name = "rand_distr"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31"
dependencies = [
"num-traits",
"rand 0.8.5",
]
[[package]]
name = "rand_xoshiro"
version = "0.6.0"
@@ -2305,6 +2490,26 @@ dependencies = [
"unicode-width 0.2.0",
]
[[package]]
name = "rayon"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.11"
@@ -2444,12 +2649,28 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "rust-stemmers"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54"
dependencies = [
"serde",
"serde_derive",
]
[[package]]
name = "rustc-demangle"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -2512,6 +2733,23 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "search"
version = "0.3.13"
dependencies = [
"anyhow",
"common",
"prost",
"serde",
"serde_json",
"sqlx",
"tantivy",
"tokio",
"tonic",
"tonic-reflection",
"tracing",
]
[[package]]
name = "security-framework"
version = "2.11.1"
@@ -2598,6 +2836,7 @@ dependencies = [
name = "server"
version = "0.3.13"
dependencies = [
"anyhow",
"bcrypt",
"chrono",
"common",
@@ -2606,13 +2845,18 @@ dependencies = [
"jsonwebtoken",
"lazy_static",
"prost",
"prost-types",
"rand 0.9.1",
"regex",
"rstest",
"rust-stemmers",
"search",
"serde",
"serde_json",
"sqlx",
"steel-core",
"steel-derive 0.5.0 (git+https://github.com/mattwparas/steel.git?branch=master)",
"tantivy",
"thiserror 2.0.12",
"time",
"tokio",
@@ -2722,6 +2966,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "sketches-ddsketch"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a"
dependencies = [
"serde",
]
[[package]]
name = "slab"
version = "0.4.9"
@@ -2771,9 +3024,9 @@ dependencies = [
[[package]]
name = "sqlx"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3a85280daca669cfd3bcb68a337882a8bc57ec882f72c5d13a430613a738e"
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
dependencies = [
"sqlx-core",
"sqlx-macros",
@@ -2784,9 +3037,9 @@ dependencies = [
[[package]]
name = "sqlx-core"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f743f2a3cea30a58cd479013f75550e879009e3a02f616f18ca699335aa248c3"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64",
"bytes",
@@ -2822,9 +3075,9 @@ dependencies = [
[[package]]
name = "sqlx-macros"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f4200e0fde19834956d4252347c12a083bdcb237d7a1a1446bffd8768417dce"
checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
dependencies = [
"proc-macro2",
"quote",
@@ -2835,9 +3088,9 @@ dependencies = [
[[package]]
name = "sqlx-macros-core"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882ceaa29cade31beca7129b6beeb05737f44f82dbe2a9806ecea5a7093d00b7"
checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
dependencies = [
"dotenvy",
"either",
@@ -2854,16 +3107,15 @@ dependencies = [
"sqlx-postgres",
"sqlx-sqlite",
"syn 2.0.100",
"tempfile",
"tokio",
"url",
]
[[package]]
name = "sqlx-mysql"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0afdd3aa7a629683c2d750c2df343025545087081ab5942593a5288855b1b7a7"
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64",
@@ -2906,9 +3158,9 @@ dependencies = [
[[package]]
name = "sqlx-postgres"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0bedbe1bbb5e2615ef347a5e9d8cd7680fb63e77d9dafc0f29be15e53f1ebe6"
checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64",
@@ -2946,9 +3198,9 @@ dependencies = [
[[package]]
name = "sqlx-sqlite"
version = "0.8.5"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c26083e9a520e8eb87a06b12347679b142dc2ea29e6e409f805644a7a979a5bc"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [
"atoi",
"chrono",
@@ -3162,6 +3414,152 @@ dependencies = [
"syn 2.0.100",
]
[[package]]
name = "tantivy"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2374a21157427c5faff2d90930f035b6c22a5d7b0e5b0b7f522e988ef33c06"
dependencies = [
"aho-corasick",
"arc-swap",
"base64",
"bitpacking",
"bon",
"byteorder",
"census",
"crc32fast",
"crossbeam-channel",
"downcast-rs",
"fastdivide",
"fnv",
"fs4",
"htmlescape",
"hyperloglogplus",
"itertools 0.14.0",
"levenshtein_automata",
"log",
"lru",
"lz4_flex",
"measure_time",
"memmap2",
"once_cell",
"oneshot",
"rayon",
"regex",
"rust-stemmers",
"rustc-hash",
"serde",
"serde_json",
"sketches-ddsketch",
"smallvec",
"tantivy-bitpacker",
"tantivy-columnar",
"tantivy-common",
"tantivy-fst",
"tantivy-query-grammar",
"tantivy-stacker",
"tantivy-tokenizer-api",
"tempfile",
"thiserror 2.0.12",
"time",
"uuid",
"winapi",
]
[[package]]
name = "tantivy-bitpacker"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1adc286a39e089ae9938935cd488d7d34f14502544a36607effd2239ff0e2494"
dependencies = [
"bitpacking",
]
[[package]]
name = "tantivy-columnar"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6300428e0c104c4f7db6f95b466a6f5c1b9aece094ec57cdd365337908dc7344"
dependencies = [
"downcast-rs",
"fastdivide",
"itertools 0.14.0",
"serde",
"tantivy-bitpacker",
"tantivy-common",
"tantivy-sstable",
"tantivy-stacker",
]
[[package]]
name = "tantivy-common"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91b6ea6090ce03dc72c27d0619e77185d26cc3b20775966c346c6d4f7e99d7f"
dependencies = [
"async-trait",
"byteorder",
"ownedbytes",
"serde",
"time",
]
[[package]]
name = "tantivy-fst"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18"
dependencies = [
"byteorder",
"regex-syntax",
"utf8-ranges",
]
[[package]]
name = "tantivy-query-grammar"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a"
dependencies = [
"nom",
"serde",
"serde_json",
]
[[package]]
name = "tantivy-sstable"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709f22c08a4c90e1b36711c1c6cad5ae21b20b093e535b69b18783dd2cb99416"
dependencies = [
"futures-util",
"itertools 0.14.0",
"tantivy-bitpacker",
"tantivy-common",
"tantivy-fst",
"zstd",
]
[[package]]
name = "tantivy-stacker"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bcdebb267671311d1e8891fd9d1301803fdb8ad21ba22e0a30d0cab49ba59c1"
dependencies = [
"murmurhash32",
"rand_distr",
"tantivy-common",
]
[[package]]
name = "tantivy-tokenizer-api"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa942fcee81e213e09715bbce8734ae2180070b97b33839a795ba1de201547d"
dependencies = [
"serde",
]
[[package]]
name = "tempfile"
version = "3.19.1"
@@ -3424,9 +3822,9 @@ dependencies = [
[[package]]
name = "tonic-reflection"
version = "0.13.0"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88fa815be858816dad226a49439ee90b7bcf81ab55bee72fdb217f1e6778c3ca"
checksum = "f9687bd5bfeafebdded2356950f278bba8226f0b32109537c4253406e09aafe1"
dependencies = [
"prost",
"prost-types",
@@ -3648,6 +4046,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
[[package]]
name = "utf8-ranges"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -3850,7 +4254,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -4214,3 +4618,31 @@ dependencies = [
"quote",
"syn 2.0.100",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.15+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237"
dependencies = [
"cc",
"pkg-config",
]

View File

@@ -1,5 +1,5 @@
[workspace]
members = ["client", "server", "common"]
members = ["client", "server", "common", "search"]
resolver = "2"
[workspace.package]
@@ -16,4 +16,28 @@ categories = ["command-line-interface"]
# [workspace.metadata]
# TODO:
# documentation = "https://docs.rs/accounting-client"`
# documentation = "https://docs.rs/accounting-client"
[workspace.dependencies]
# Async and gRPC
tokio = { version = "1.44.2", features = ["full"] }
tonic = "0.13.0"
prost = "0.13.5"
async-trait = "0.1.88"
prost-types = "0.13.0"
# Data Handling & Serialization
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
time = "0.3.41"
# Utilities & Error Handling
anyhow = "1.0.98"
dotenvy = "0.15.7"
lazy_static = "1.5.0"
tracing = "0.1.41"
# Search crate
tantivy = "0.24.1"
common = { path = "./common" }

View File

@@ -18,3 +18,8 @@ Client with tracing:
```
ENABLE_TRACING=1 RUST_LOG=client=debug cargo watch -x 'run --package client -- client'
```
Client with debug that cant be traced
```
cargo run --package client --features ui-debug -- client
```

View File

@@ -9,6 +9,7 @@ anyhow = "1.0.98"
async-trait = "0.1.88"
common = { path = "../common" }
prost-types = { workspace = true }
crossterm = "0.28.1"
dirs = "6.0.0"
dotenvy = "0.15.7"
@@ -26,3 +27,7 @@ tracing-subscriber = "0.3.19"
tui-textarea = { version = "0.7.0", features = ["crossterm", "ratatui", "search"] }
unicode-segmentation = "1.12.0"
unicode-width = "0.2.0"
[features]
default = []
ui-debug = []

View File

@@ -17,6 +17,7 @@ toggle_buffer_list = ["ctrl+b"]
next_field = ["Tab"]
prev_field = ["Shift+Tab"]
exit_table_scroll = ["esc"]
open_search = ["ctrl+f"]
[keybindings.common]
save = ["ctrl+s"]
@@ -69,10 +70,11 @@ prev_field = ["shift+enter"]
exit = ["esc", "ctrl+e"]
delete_char_forward = ["delete"]
delete_char_backward = ["backspace"]
move_left = ["left"]
move_left = [""]
move_right = ["right"]
suggestion_down = ["ctrl+n", "tab"]
suggestion_up = ["ctrl+p", "shift+tab"]
trigger_autocomplete = ["left"]
[keybindings.command]
exit_command_mode = ["ctrl+g", "esc"]
@@ -83,6 +85,7 @@ quit = ["q"]
force_quit = ["q!"]
save_and_quit = ["wq"]
revert = ["r"]
find_file_palette_toggle = ["ff"]
[editor]
keybinding_mode = "vim" # Options: "default", "vim", "emacs"

View File

@@ -5,6 +5,8 @@ pub mod text_editor;
pub mod background;
pub mod dialog;
pub mod autocomplete;
pub mod search_palette;
pub mod find_file_palette;
pub use command_line::*;
pub use status_line::*;
@@ -12,3 +14,5 @@ pub use text_editor::*;
pub use background::*;
pub use dialog::*;
pub use autocomplete::*;
pub use search_palette::*;
pub use find_file_palette::*;

View File

@@ -1,6 +1,8 @@
// src/components/common/autocomplete.rs
use crate::config::colors::themes::Theme;
use crate::state::pages::form::FormState;
use common::proto::multieko2::search::search_response::Hit;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
@@ -9,7 +11,8 @@ use ratatui::{
};
use unicode_width::UnicodeWidthStr;
/// Renders an opaque dropdown list for autocomplete suggestions.
/// Renders an opaque dropdown list for simple string-based suggestions.
/// THIS IS THE RESTORED FUNCTION.
pub fn render_autocomplete_dropdown(
f: &mut Frame,
input_rect: Rect,
@@ -21,39 +24,32 @@ pub fn render_autocomplete_dropdown(
if suggestions.is_empty() {
return;
}
// --- Calculate Dropdown Size & Position ---
let max_suggestion_width = suggestions.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
let max_suggestion_width =
suggestions.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
let horizontal_padding: u16 = 2;
let dropdown_width = (max_suggestion_width + horizontal_padding).max(10);
let dropdown_height = (suggestions.len() as u16).min(5);
let mut dropdown_area = Rect {
x: input_rect.x, // Align horizontally with input
y: input_rect.y + 1, // Position directly below input
x: input_rect.x,
y: input_rect.y + 1,
width: dropdown_width,
height: dropdown_height,
};
// --- Clamping Logic (prevent rendering off-screen) ---
// Clamp vertically (if it goes below the frame)
if dropdown_area.bottom() > frame_area.height {
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height); // Try rendering above
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
}
// Clamp horizontally (if it goes past the right edge)
if dropdown_area.right() > frame_area.width {
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
}
// Ensure x is not negative (if clamping pushes it left)
dropdown_area.x = dropdown_area.x.max(0);
// Ensure y is not negative (if clamping pushes it up)
dropdown_area.y = dropdown_area.y.max(0);
// --- End Clamping ---
// Render a solid background block first to ensure opacity
let background_block = Block::default().style(Style::default().bg(Color::DarkGray));
let background_block =
Block::default().style(Style::default().bg(Color::DarkGray));
f.render_widget(background_block, dropdown_area);
// Create list items, ensuring each has a defined background
let items: Vec<ListItem> = suggestions
.iter()
.enumerate()
@@ -61,30 +57,97 @@ pub fn render_autocomplete_dropdown(
let is_selected = selected_index == Some(i);
let s_width = s.width() as u16;
let padding_needed = dropdown_width.saturating_sub(s_width);
let padded_s = format!("{}{}", s, " ".repeat(padding_needed as usize));
let padded_s =
format!("{}{}", s, " ".repeat(padding_needed as usize));
ListItem::new(padded_s).style(if is_selected {
Style::default()
.fg(theme.bg) // Text color on highlight
.bg(theme.highlight) // Highlight background
.fg(theme.bg)
.bg(theme.highlight)
.add_modifier(Modifier::BOLD)
} else {
// Style for non-selected items (matching background block)
Style::default()
.fg(theme.fg) // Text color on gray
.bg(Color::DarkGray) // Explicit gray background
Style::default().fg(theme.fg).bg(Color::DarkGray)
})
})
.collect();
// Create the list widget (without its own block)
let list = List::new(items);
let mut list_state = ListState::default();
list_state.select(selected_index);
// State for managing selection highlight (still needed for logic)
let mut profile_list_state = ListState::default();
profile_list_state.select(selected_index);
// Render the list statefully *over* the background block
f.render_stateful_widget(list, dropdown_area, &mut profile_list_state);
f.render_stateful_widget(list, dropdown_area, &mut list_state);
}
/// Renders an opaque dropdown list for rich `Hit`-based suggestions.
/// RENAMED from render_rich_autocomplete_dropdown
pub fn render_hit_autocomplete_dropdown(
f: &mut Frame,
input_rect: Rect,
frame_area: Rect,
theme: &Theme,
suggestions: &[Hit],
selected_index: Option<usize>,
form_state: &FormState,
) {
if suggestions.is_empty() {
return;
}
let display_names: Vec<String> = suggestions
.iter()
.map(|hit| form_state.get_display_name_for_hit(hit))
.collect();
let max_suggestion_width =
display_names.iter().map(|s| s.width()).max().unwrap_or(0) as u16;
let horizontal_padding: u16 = 2;
let dropdown_width = (max_suggestion_width + horizontal_padding).max(10);
let dropdown_height = (suggestions.len() as u16).min(5);
let mut dropdown_area = Rect {
x: input_rect.x,
y: input_rect.y + 1,
width: dropdown_width,
height: dropdown_height,
};
if dropdown_area.bottom() > frame_area.height {
dropdown_area.y = input_rect.y.saturating_sub(dropdown_height);
}
if dropdown_area.right() > frame_area.width {
dropdown_area.x = frame_area.width.saturating_sub(dropdown_width);
}
dropdown_area.x = dropdown_area.x.max(0);
dropdown_area.y = dropdown_area.y.max(0);
let background_block =
Block::default().style(Style::default().bg(Color::DarkGray));
f.render_widget(background_block, dropdown_area);
let items: Vec<ListItem> = display_names
.iter()
.enumerate()
.map(|(i, s)| {
let is_selected = selected_index == Some(i);
let s_width = s.width() as u16;
let padding_needed = dropdown_width.saturating_sub(s_width);
let padded_s =
format!("{}{}", s, " ".repeat(padding_needed as usize));
ListItem::new(padded_s).style(if is_selected {
Style::default()
.fg(theme.bg)
.bg(theme.highlight)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.fg).bg(Color::DarkGray)
})
})
.collect();
let list = List::new(items);
let mut list_state = ListState::default();
list_state.select(selected_index);
f.render_stateful_widget(list, dropdown_area, &mut list_state);
}

View File

@@ -1,4 +1,5 @@
// src/client/components/command_line.rs
// src/components/common/command_line.rs
use ratatui::{
widgets::{Block, Paragraph},
style::Style,
@@ -6,30 +7,63 @@ use ratatui::{
Frame,
};
use crate::config::colors::themes::Theme;
use unicode_width::UnicodeWidthStr; // Import for width calculation
pub fn render_command_line(f: &mut Frame, area: Rect, input: &str, active: bool, theme: &Theme, message: &str) {
let prompt = if active {
":"
} else {
""
pub fn render_command_line(
f: &mut Frame,
area: Rect,
input: &str, // This is event_handler.command_input
active: bool, // This is event_handler.command_mode
theme: &Theme,
message: &str, // This is event_handler.command_message
) {
// Original logic for determining display_text
let display_text = if !active {
// If not in normal command mode, but there's a message (e.g. from Find File palette closing)
// Or if command mode is off and message is empty (render minimally)
if message.is_empty() {
"".to_string() // Render an empty string, background will cover
} else {
message.to_string()
}
} else { // active is true (normal command mode)
let prompt = ":";
if message.is_empty() || message == ":" {
format!("{}{}", prompt, input)
} else {
if input.is_empty() { // If command was just executed, input is cleared, show message
message.to_string()
} else { // Show input and message
format!("{}{} | {}", prompt, input, message)
}
}
};
// Combine the prompt, input, and message
let display_text = if message.is_empty() {
format!("{}{}", prompt, input)
let content_width = UnicodeWidthStr::width(display_text.as_str());
let available_width = area.width as usize;
let padding_needed = available_width.saturating_sub(content_width);
let display_text_padded = if padding_needed > 0 {
format!("{}{}", display_text, " ".repeat(padding_needed))
} else {
format!("{}{} | {}", prompt, input, message)
// If text is too long, ratatui's Paragraph will handle truncation.
// We could also truncate here if specific behavior is needed:
// display_text.chars().take(available_width).collect::<String>()
display_text
};
let style = if active {
// Determine style based on active state, but apply to the whole paragraph
let text_style = if active {
Style::default().fg(theme.accent)
} else {
// If not active, but there's a message, use default foreground.
// If message is also empty, this style won't matter much for empty text.
Style::default().fg(theme.fg)
};
let paragraph = Paragraph::new(display_text)
.block(Block::default().style(Style::default().bg(theme.bg)))
.style(style);
let paragraph = Paragraph::new(display_text_padded)
.block(Block::default().style(Style::default().bg(theme.bg))) // Block ensures bg for whole area
.style(text_style); // Style for the text itself
f.render_widget(paragraph, area);
}

View File

@@ -0,0 +1,142 @@
// src/components/common/find_file_palette.rs
use crate::config::colors::themes::Theme;
use crate::modes::general::command_navigation::NavigationState; // Corrected path
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::Style,
widgets::{Block, List, ListItem, Paragraph},
Frame,
};
use unicode_width::UnicodeWidthStr;
const PALETTE_MAX_VISIBLE_OPTIONS: usize = 15;
const PADDING_CHAR: &str = " ";
pub fn render_find_file_palette(
f: &mut Frame,
area: Rect,
theme: &Theme,
navigation_state: &NavigationState,
) {
let palette_display_input = navigation_state.get_display_input(); // Use the new method
let num_total_filtered = navigation_state.filtered_options.len();
let current_selected_list_idx = navigation_state.selected_index;
let mut display_start_offset = 0;
if num_total_filtered > PALETTE_MAX_VISIBLE_OPTIONS {
if let Some(sel_idx) = current_selected_list_idx {
if sel_idx >= display_start_offset + PALETTE_MAX_VISIBLE_OPTIONS {
display_start_offset = sel_idx - PALETTE_MAX_VISIBLE_OPTIONS + 1;
} else if sel_idx < display_start_offset {
display_start_offset = sel_idx;
}
display_start_offset = display_start_offset
.min(num_total_filtered.saturating_sub(PALETTE_MAX_VISIBLE_OPTIONS));
}
}
display_start_offset = display_start_offset.max(0);
let display_end_offset = (display_start_offset + PALETTE_MAX_VISIBLE_OPTIONS)
.min(num_total_filtered);
// navigation_state.filtered_options is Vec<(usize, String)>
// We only need the String part for display.
let visible_options_slice: Vec<&String> = if num_total_filtered > 0 {
navigation_state.filtered_options
[display_start_offset..display_end_offset]
.iter()
.map(|(_, opt_str)| opt_str)
.collect()
} else {
Vec::new()
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // For palette input line
Constraint::Min(0), // For options list, take remaining space
])
.split(area);
// Ensure list_area height does not exceed PALETTE_MAX_VISIBLE_OPTIONS
let list_area_height = std::cmp::min(chunks[1].height, PALETTE_MAX_VISIBLE_OPTIONS as u16);
let final_list_area = Rect::new(chunks[1].x, chunks[1].y, chunks[1].width, list_area_height);
let input_area = chunks[0];
// let list_area = chunks[1]; // Use final_list_area
let prompt_prefix = match navigation_state.navigation_type {
crate::modes::general::command_navigation::NavigationType::FindFile => "Find File: ",
crate::modes::general::command_navigation::NavigationType::TableTree => "Table Path: ",
};
let base_prompt_text = format!("{}{}", prompt_prefix, palette_display_input);
let prompt_text_width = UnicodeWidthStr::width(base_prompt_text.as_str());
let input_area_width = input_area.width as usize;
let input_padding_needed =
input_area_width.saturating_sub(prompt_text_width);
let padded_prompt_text = if input_padding_needed > 0 {
format!(
"{}{}",
base_prompt_text,
PADDING_CHAR.repeat(input_padding_needed)
)
} else {
base_prompt_text
};
let input_paragraph = Paragraph::new(padded_prompt_text)
.style(Style::default().fg(theme.accent).bg(theme.bg));
f.render_widget(input_paragraph, input_area);
let mut display_list_items: Vec<ListItem> =
Vec::with_capacity(PALETTE_MAX_VISIBLE_OPTIONS);
for (idx_in_visible_slice, opt_str) in
visible_options_slice.iter().enumerate()
{
// The selected_index in navigation_state is relative to the full filtered_options list.
// We need to check if the current item (from the visible slice) corresponds to the selected_index.
let original_filtered_idx = display_start_offset + idx_in_visible_slice;
let is_selected =
current_selected_list_idx == Some(original_filtered_idx);
let style = if is_selected {
Style::default().fg(theme.bg).bg(theme.accent)
} else {
Style::default().fg(theme.fg).bg(theme.bg)
};
let opt_width = opt_str.width() as u16;
let list_item_width = final_list_area.width;
let padding_amount = list_item_width.saturating_sub(opt_width);
let padded_opt_str = format!(
"{}{}",
opt_str,
PADDING_CHAR.repeat(padding_amount as usize)
);
display_list_items.push(ListItem::new(padded_opt_str).style(style));
}
// Fill remaining lines in the list area to maintain fixed height appearance
let num_rendered_options = display_list_items.len();
if num_rendered_options < PALETTE_MAX_VISIBLE_OPTIONS && (final_list_area.height as usize) > num_rendered_options {
for _ in num_rendered_options..(final_list_area.height as usize) {
let empty_padded_str =
PADDING_CHAR.repeat(final_list_area.width as usize);
display_list_items.push(
ListItem::new(empty_padded_str)
.style(Style::default().fg(theme.bg).bg(theme.bg)),
);
}
}
let options_list_widget = List::new(display_list_items)
.block(Block::default().style(Style::default().bg(theme.bg)));
f.render_widget(options_list_widget, final_list_area);
}

View File

@@ -0,0 +1,121 @@
// src/components/common/search_palette.rs
use crate::config::colors::themes::Theme;
use crate::state::app::search::SearchState;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
/// Renders the search palette dialog over the main UI.
pub fn render_search_palette(
f: &mut Frame,
area: Rect,
theme: &Theme,
state: &SearchState,
) {
// --- Dialog Area Calculation ---
let height = (area.height as f32 * 0.7).min(30.0) as u16;
let width = (area.width as f32 * 0.6).min(100.0) as u16;
let dialog_area = Rect {
x: area.x + (area.width - width) / 2,
y: area.y + (area.height - height) / 4,
width,
height,
};
f.render_widget(Clear, dialog_area); // Clear background
let block = Block::default()
.title(format!(" Search in '{}' ", state.table_name))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.accent));
f.render_widget(block.clone(), dialog_area);
// --- Inner Layout (Input + Results) ---
let inner_chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([
Constraint::Length(3), // For input box
Constraint::Min(0), // For results list
])
.split(dialog_area);
// --- Render Input Box ---
let input_block = Block::default()
.title("Query")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border));
let input_text = Paragraph::new(state.input.as_str())
.block(input_block)
.style(Style::default().fg(theme.fg));
f.render_widget(input_text, inner_chunks[0]);
// Set cursor position
f.set_cursor(
inner_chunks[0].x + state.cursor_position as u16 + 1,
inner_chunks[0].y + 1,
);
// --- Render Results List ---
if state.is_loading {
let loading_p = Paragraph::new("Searching...")
.style(Style::default().fg(theme.fg).add_modifier(Modifier::ITALIC));
f.render_widget(loading_p, inner_chunks[1]);
} else {
let list_items: Vec<ListItem> = state
.results
.iter()
.map(|hit| {
// Parse the JSON string to make it readable
let content_summary = match serde_json::from_str::<
serde_json::Value,
>(&hit.content_json)
{
Ok(json) => {
if let Some(obj) = json.as_object() {
// Create a summary from the first few non-null string values
obj.values()
.filter_map(|v| v.as_str())
.filter(|s| !s.is_empty())
.take(3)
.collect::<Vec<_>>()
.join(" | ")
} else {
"Non-object JSON".to_string()
}
}
Err(_) => "Invalid JSON content".to_string(),
};
let line = Line::from(vec![
Span::styled(
format!("{:<4.2} ", hit.score),
Style::default().fg(theme.accent),
),
Span::raw(content_summary),
]);
ListItem::new(line)
})
.collect();
let results_list = List::new(list_items)
.block(Block::default().title("Results"))
.highlight_style(
Style::default()
.bg(theme.highlight)
.fg(theme.bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
// We need a mutable ListState to render the selection
let mut list_state =
ratatui::widgets::ListState::default().with_selected(Some(state.selected_index));
f.render_stateful_widget(results_list, inner_chunks[1], &mut list_state);
}
}

View File

@@ -1,13 +1,15 @@
use ratatui::{
style::Style,
layout::Rect,
Frame,
text::{Line, Span},
widgets::Paragraph,
};
use unicode_width::UnicodeWidthStr;
// client/src/components/common/status_line.rs
use crate::config::colors::themes::Theme;
use crate::state::app::state::AppState;
use ratatui::{
layout::Rect,
style::Style,
text::{Line, Span, Text},
widgets::{Paragraph, Wrap}, // Make sure Wrap is imported
Frame,
};
use std::path::Path;
use unicode_width::UnicodeWidthStr;
pub fn render_status_line(
f: &mut Frame,
@@ -16,11 +18,41 @@ pub fn render_status_line(
theme: &Theme,
is_edit_mode: bool,
current_fps: f64,
app_state: &AppState,
) {
#[cfg(feature = "ui-debug")]
{
if let Some(debug_state) = &app_state.debug_state {
let paragraph = if debug_state.is_error {
// --- THIS IS THE CRITICAL LOGIC FOR ERRORS ---
// 1. Create a `Text` object, which can contain multiple lines.
let error_text = Text::from(debug_state.displayed_message.clone());
// 2. Create a Paragraph from the Text and TELL IT TO WRAP.
Paragraph::new(error_text)
.wrap(Wrap { trim: true }) // This line makes the text break into new rows.
.style(Style::default().bg(theme.highlight).fg(theme.bg))
} else {
// --- This is for normal, single-line info messages ---
Paragraph::new(debug_state.displayed_message.as_str())
.style(Style::default().fg(theme.accent).bg(theme.bg))
};
f.render_widget(paragraph, area);
} else {
// Fallback for when debug state is None
let paragraph = Paragraph::new("").style(Style::default().bg(theme.bg));
f.render_widget(paragraph, area);
}
return; // Stop here and don't render the normal status line.
}
// --- The normal status line rendering logic (unchanged) ---
let program_info = format!("multieko2 v{}", env!("CARGO_PKG_VERSION"));
let mode_text = if is_edit_mode { "[EDIT]" } else { "[READ-ONLY]" };
let home_dir = dirs::home_dir().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
let home_dir = dirs::home_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let display_dir = if current_dir.starts_with(&home_dir) {
current_dir.replacen(&home_dir, "~", 1)
} else {
@@ -35,16 +67,30 @@ pub fn render_status_line(
let separator = " | ";
let separator_width = UnicodeWidthStr::width(separator);
let fixed_width_with_fps = mode_width + separator_width + separator_width +
program_info_width + separator_width + fps_width;
let show_fps = fixed_width_with_fps < available_width;
let fixed_width_with_fps = mode_width
+ separator_width
+ separator_width
+ program_info_width
+ separator_width
+ fps_width;
let show_fps = fixed_width_with_fps <= available_width;
let remaining_width_for_dir = available_width.saturating_sub(
mode_width + separator_width + separator_width + program_info_width +
if show_fps { separator_width + fps_width } else { 0 }
mode_width
+ separator_width
+ separator_width
+ program_info_width
+ (if show_fps {
separator_width + fps_width
} else {
0
}),
);
let dir_display_text = if UnicodeWidthStr::width(display_dir.as_str()) <= remaining_width_for_dir {
let dir_display_text_str = if UnicodeWidthStr::width(display_dir.as_str())
<= remaining_width_for_dir
{
display_dir
} else {
let dir_name = Path::new(current_dir)
@@ -54,25 +100,55 @@ pub fn render_status_line(
if UnicodeWidthStr::width(dir_name) <= remaining_width_for_dir {
dir_name.to_string()
} else {
dir_name.chars().take(remaining_width_for_dir).collect()
dir_name
.chars()
.take(remaining_width_for_dir)
.collect::<String>()
}
};
let mut spans = vec![
let mut current_content_width = mode_width
+ separator_width
+ UnicodeWidthStr::width(dir_display_text_str.as_str())
+ separator_width
+ program_info_width;
if show_fps {
current_content_width += separator_width + fps_width;
}
let mut line_spans = vec![
Span::styled(mode_text, Style::default().fg(theme.accent)),
Span::styled(" | ", Style::default().fg(theme.border)),
Span::styled(dir_display_text, Style::default().fg(theme.fg)),
Span::styled(" | ", Style::default().fg(theme.border)),
Span::styled(program_info, Style::default().fg(theme.secondary)),
Span::styled(separator, Style::default().fg(theme.border)),
Span::styled(
dir_display_text_str.as_str(),
Style::default().fg(theme.fg),
),
Span::styled(separator, Style::default().fg(theme.border)),
Span::styled(
program_info.as_str(),
Style::default().fg(theme.secondary),
),
];
if show_fps {
spans.push(Span::styled(" | ", Style::default().fg(theme.border)));
spans.push(Span::styled(fps_text, Style::default().fg(theme.secondary)));
line_spans
.push(Span::styled(separator, Style::default().fg(theme.border)));
line_spans.push(Span::styled(
fps_text.as_str(),
Style::default().fg(theme.secondary),
));
}
let paragraph = Paragraph::new(Line::from(spans))
.style(Style::default().bg(theme.bg));
let padding_needed = available_width.saturating_sub(current_content_width);
if padding_needed > 0 {
line_spans.push(Span::styled(
" ".repeat(padding_needed),
Style::default().bg(theme.bg),
));
}
let paragraph =
Paragraph::new(Line::from(line_spans)).style(Style::default().bg(theme.bg));
f.render_widget(paragraph, area);
}

View File

@@ -1,69 +1,115 @@
// src/components/form/form.rs
use crate::components::common::autocomplete; // <--- ADD THIS IMPORT
use crate::components::handlers::canvas::render_canvas;
use crate::config::colors::themes::Theme;
use crate::state::app::highlight::HighlightState;
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::form::FormState; // <--- CHANGE THIS IMPORT
use ratatui::{
widgets::{Paragraph, Block, Borders},
layout::{Layout, Constraint, Direction, Rect, Margin, Alignment},
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::Style,
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::config::colors::themes::Theme;
use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::highlight::HighlightState;
use crate::components::handlers::canvas::render_canvas;
pub fn render_form(
f: &mut Frame,
area: Rect,
form_state: &impl CanvasState,
form_state: &FormState, // <--- CHANGE THIS to the concrete type
fields: &[&str],
current_field: &usize,
current_field_idx: &usize,
inputs: &[&String],
table_name: &str,
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
total_count: u64,
current_position: u64,
) {
// Create Adresar card
let card_title = format!(" {} ", table_name);
let adresar_card = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border))
.title(" Adresar ")
.title(card_title)
.style(Style::default().bg(theme.bg).fg(theme.fg));
f.render_widget(adresar_card, area);
// Define inner area
let inner_area = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
// Create main layout
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(1),
])
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(inner_area);
// Render count/position
let count_position_text = format!("Total: {} | Position: {}", total_count, current_position);
let count_position_text = if total_count == 0 && current_position == 1 {
"Total: 0 | New Entry".to_string()
} else if current_position > total_count && total_count > 0 {
format!("Total: {} | New Entry ({})", total_count, current_position)
} else if total_count == 0 && current_position > 1 {
format!("Total: 0 | New Entry ({})", current_position)
} else {
format!(
"Total: {} | Position: {}/{}",
total_count, current_position, total_count
)
};
let count_para = Paragraph::new(count_position_text)
.style(Style::default().fg(theme.fg))
.alignment(Alignment::Left);
f.render_widget(count_para, main_layout[0]);
// Delegate input handling to canvas
render_canvas(
// Get the active field's rect from render_canvas
let active_field_rect = render_canvas(
f,
main_layout[1],
form_state,
fields,
current_field,
current_field_idx,
inputs,
theme,
is_edit_mode,
highlight_state,
);
// --- NEW: RENDER AUTOCOMPLETE ---
if form_state.autocomplete_active {
if let Some(active_rect) = active_field_rect {
let selected_index = form_state.get_selected_suggestion_index();
if let Some(rich_suggestions) = form_state.get_rich_suggestions() {
if !rich_suggestions.is_empty() {
// CHANGE THIS to call the renamed function
autocomplete::render_hit_autocomplete_dropdown(
f,
active_rect,
f.area(),
theme,
rich_suggestions,
selected_index,
form_state,
);
}
}
// The fallback to simple suggestions is now correctly handled
// because the original render_autocomplete_dropdown exists again.
else if let Some(simple_suggestions) = form_state.get_suggestions() {
if !simple_suggestions.is_empty() {
autocomplete::render_autocomplete_dropdown(
f,
active_rect,
f.area(),
theme,
simple_suggestions,
selected_index,
);
}
}
}
}
}

View File

@@ -18,7 +18,7 @@ pub fn render_buffer_list(
area: Rect,
theme: &Theme,
buffer_state: &BufferState,
app_state: &AppState, // Add this parameter
app_state: &AppState,
) {
// --- Style Definitions ---
let active_style = Style::default()
@@ -39,8 +39,7 @@ pub fn render_buffer_list(
let mut spans = Vec::new();
let mut current_width = 0;
// TODO: Replace with actual table name from server response
let current_table_name = Some("2025_customer");
let current_table_name = app_state.current_view_table_name.as_deref();
for (original_index, view) in buffer_state.history.iter().enumerate() {
// Filter: Only process views matching the active layer

View File

@@ -1,16 +1,16 @@
// src/components/handlers/canvas.rs
use ratatui::{
widgets::{Paragraph, Block, Borders},
layout::{Layout, Constraint, Direction, Rect},
style::{Style, Modifier},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
prelude::Alignment,
};
use crate::config::colors::themes::Theme;
use crate::state::app::highlight::HighlightState;
use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::highlight::HighlightState; // Ensure correct import path
use std::cmp::{min, max};
use std::cmp::{max, min};
pub fn render_canvas(
f: &mut Frame,
@@ -21,9 +21,8 @@ pub fn render_canvas(
inputs: &[&String],
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState, // Using the enum state
highlight_state: &HighlightState,
) -> Option<Rect> {
// ... (setup code remains the same) ...
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
@@ -58,46 +57,47 @@ pub fn render_canvas(
let mut active_field_input_rect = None;
// Render labels
for (i, field) in fields.iter().enumerate() {
let label = Paragraph::new(Line::from(Span::styled(
format!("{}:", field),
Style::default().fg(theme.fg)),
));
f.render_widget(label, Rect {
x: columns[0].x,
y: input_block.y + 1 + i as u16,
width: columns[0].width,
height: 1,
});
Style::default().fg(theme.fg),
)));
f.render_widget(
label,
Rect {
x: columns[0].x,
y: input_block.y + 1 + i as u16,
width: columns[0].width,
height: 1,
},
);
}
// Render inputs and cursor
for (i, input) in inputs.iter().enumerate() {
for (i, _input) in inputs.iter().enumerate() {
let is_active = i == *current_field_idx;
let current_cursor_pos = form_state.current_cursor_pos();
let text = input.as_str();
let text_len = text.chars().count();
// Use the trait method to get display value
let text = form_state.get_display_value_for_field(i);
let text_len = text.chars().count();
let line: Line;
// --- Use match on the highlight_state enum ---
match highlight_state {
HighlightState::Off => {
// Not in highlight mode, render normally
line = Line::from(Span::styled(
text,
if is_active { Style::default().fg(theme.highlight) } else { Style::default().fg(theme.fg) }
if is_active {
Style::default().fg(theme.highlight)
} else {
Style::default().fg(theme.fg)
},
));
}
HighlightState::Characterwise { anchor } => {
// --- Character-wise Highlight Logic ---
let (anchor_field, anchor_char) = *anchor;
let start_field = min(anchor_field, *current_field_idx);
let end_field = max(anchor_field, *current_field_idx);
// Use start_char and end_char consistently
let (start_char, end_char) = if anchor_field == *current_field_idx {
(min(anchor_char, current_cursor_pos), max(anchor_char, current_cursor_pos))
} else if anchor_field < *current_field_idx {
@@ -111,24 +111,20 @@ pub fn render_canvas(
let normal_style_outside = Style::default().fg(theme.fg);
if i >= start_field && i <= end_field {
// This line is within the character-wise highlight range
if start_field == end_field { // Case 1: Single Line Highlight
// Use start_char and end_char here
if start_field == end_field {
let clamped_start = start_char.min(text_len);
let clamped_end = end_char.min(text_len); // Use text_len for slicing logic
let clamped_end = end_char.min(text_len);
let before: String = text.chars().take(clamped_start).collect();
let highlighted: String = text.chars().skip(clamped_start).take(clamped_end.saturating_sub(clamped_start) + 1).collect();
// Define 'after' here
let after: String = text.chars().skip(clamped_end + 1).collect();
line = Line::from(vec![
Span::styled(before, normal_style_in_highlight),
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style_in_highlight), // Use defined 'after'
Span::styled(after, normal_style_in_highlight),
]);
} else if i == start_field { // Case 2: Multi-Line Highlight - Start Line
// Use start_char here
} else if i == start_field {
let safe_start = start_char.min(text_len);
let before: String = text.chars().take(safe_start).collect();
let highlighted: String = text.chars().skip(safe_start).collect();
@@ -136,8 +132,7 @@ pub fn render_canvas(
Span::styled(before, normal_style_in_highlight),
Span::styled(highlighted, highlight_style),
]);
} else if i == end_field { // Case 3: Multi-Line Highlight - End Line (Corrected index)
// Use end_char here
} else if i == end_field {
let safe_end_inclusive = if text_len > 0 { end_char.min(text_len - 1) } else { 0 };
let highlighted: String = text.chars().take(safe_end_inclusive + 1).collect();
let after: String = text.chars().skip(safe_end_inclusive + 1).collect();
@@ -145,19 +140,17 @@ pub fn render_canvas(
Span::styled(highlighted, highlight_style),
Span::styled(after, normal_style_in_highlight),
]);
} else { // Case 4: Multi-Line Highlight - Middle Line (Corrected index)
line = Line::from(Span::styled(text, highlight_style)); // Highlight whole line
} else {
line = Line::from(Span::styled(text, highlight_style));
}
} else { // Case 5: Line Outside Character-wise Highlight Range
} else {
line = Line::from(Span::styled(
text,
// Use normal styling (active or inactive)
if is_active { normal_style_in_highlight } else { normal_style_outside }
));
}
}
HighlightState::Linewise { anchor_line } => {
// --- Linewise Highlight Logic ---
let start_field = min(*anchor_line, *current_field_idx);
let end_field = max(*anchor_line, *current_field_idx);
let highlight_style = Style::default().fg(theme.highlight).bg(theme.highlight_bg).add_modifier(Modifier::BOLD);
@@ -165,25 +158,31 @@ pub fn render_canvas(
let normal_style_outside = Style::default().fg(theme.fg);
if i >= start_field && i <= end_field {
// Highlight the entire line
line = Line::from(Span::styled(text, highlight_style));
} else {
// Line outside linewise highlight range
line = Line::from(Span::styled(
text,
// Use normal styling (active or inactive)
if is_active { normal_style_in_highlight } else { normal_style_outside }
));
}
}
} // End match highlight_state
}
let input_display = Paragraph::new(line).alignment(Alignment::Left);
f.render_widget(input_display, input_rows[i]);
if is_active {
active_field_input_rect = Some(input_rows[i]);
let cursor_x = input_rows[i].x + form_state.current_cursor_pos() as u16;
// --- CORRECTED CURSOR POSITIONING LOGIC ---
// Use the new generic trait method to check for an override.
let cursor_x = if form_state.has_display_override(i) {
// If an override exists, place the cursor at the end.
input_rows[i].x + text.chars().count() as u16
} else {
// Otherwise, use the real cursor position.
input_rows[i].x + form_state.current_cursor_pos() as u16
};
let cursor_y = input_rows[i].y;
f.set_cursor_position((cursor_x, cursor_y));
}
@@ -191,4 +190,3 @@ pub fn render_canvas(
active_field_input_rect
}

View File

@@ -4,6 +4,7 @@ use crate::services::grpc_client::GrpcClient;
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::form::FormState;
use crate::state::pages::auth::RegisterState;
use crate::state::app::state::AppState;
use crate::tui::functions::common::form::{revert, save};
use crossterm::event::{KeyCode, KeyEvent};
use std::any::Any;
@@ -13,6 +14,7 @@ pub async fn execute_common_action<S: CanvasState + Any>(
action: &str,
state: &mut S,
grpc_client: &mut GrpcClient,
app_state: &AppState,
current_position: &mut u64,
total_count: u64,
) -> Result<String> {
@@ -27,10 +29,9 @@ pub async fn execute_common_action<S: CanvasState + Any>(
match action {
"save" => {
let outcome = save(
app_state,
form_state,
grpc_client,
current_position,
total_count,
)
.await?;
let message = format!("Save successful: {:?}", outcome); // Simple message for now
@@ -40,8 +41,6 @@ pub async fn execute_common_action<S: CanvasState + Any>(
revert(
form_state,
grpc_client,
current_position,
total_count,
)
.await
}

View File

@@ -3,6 +3,7 @@
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::form::FormState;
use crate::state::app::state::AppState;
use crate::tui::functions::common::form::{revert, save};
use crate::tui::functions::common::form::SaveOutcome;
use crate::modes::handlers::event::EventOutcome;
@@ -14,8 +15,7 @@ pub async fn execute_common_action<S: CanvasState + Any>(
action: &str,
state: &mut S,
grpc_client: &mut GrpcClient,
current_position: &mut u64,
total_count: u64,
app_state: &AppState,
) -> Result<EventOutcome> {
match action {
"save" | "revert" => {
@@ -28,12 +28,11 @@ pub async fn execute_common_action<S: CanvasState + Any>(
match action {
"save" => {
let save_result = save(
app_state,
form_state,
grpc_client,
current_position,
total_count,
).await;
match save_result {
Ok(save_outcome) => {
let message = match save_outcome {
@@ -50,10 +49,8 @@ pub async fn execute_common_action<S: CanvasState + Any>(
let revert_result = revert(
form_state,
grpc_client,
current_position,
total_count,
).await;
match revert_result {
Ok(message) => Ok(EventOutcome::Ok(message)),
Err(e) => Err(e),

View File

@@ -7,6 +7,7 @@ pub mod components;
pub mod modes;
pub mod functions;
pub mod services;
pub mod utils;
pub use ui::run_ui;

View File

@@ -1,5 +1,7 @@
// client/src/main.rs
use client::run_ui;
#[cfg(feature = "ui-debug")]
use client::utils::debug_logger::UiDebugWriter;
use dotenvy::dotenv;
use anyhow::Result;
use tracing_subscriber;
@@ -7,8 +9,22 @@ use std::env;
#[tokio::main]
async fn main() -> Result<()> {
if env::var("ENABLE_TRACING").is_ok() {
tracing_subscriber::fmt::init();
#[cfg(feature = "ui-debug")]
{
// If ui-debug is on, set up our custom writer.
let writer = UiDebugWriter::new();
tracing_subscriber::fmt()
.with_level(false) // Don't show INFO, ERROR, etc.
.with_target(false) // Don't show the module path.
.without_time() // This is the correct and simpler method.
.with_writer(move || writer.clone())
.init();
}
#[cfg(not(feature = "ui-debug"))]
{
if env::var("ENABLE_TRACING").is_ok() {
tracing_subscriber::fmt::init();
}
}
dotenv().ok();

View File

@@ -24,8 +24,6 @@ pub async fn handle_core_action(
auth_client: &mut AuthClient,
terminal: &mut TerminalCore,
app_state: &mut AppState,
current_position: &mut u64,
total_count: u64,
) -> Result<EventOutcome> {
match action {
"save" => {
@@ -34,10 +32,9 @@ pub async fn handle_core_action(
Ok(EventOutcome::Ok(message))
} else {
let save_outcome = form_save(
app_state,
form_state,
grpc_client,
current_position,
total_count,
).await.context("Register save action failed")?;
let message = match save_outcome {
SaveOutcome::NoChange => "No changes to save.".to_string(),
@@ -56,10 +53,9 @@ pub async fn handle_core_action(
login_save(auth_state, login_state, auth_client, app_state).await.context("Login save n quit action failed")?
} else {
let save_outcome = form_save(
app_state,
form_state,
grpc_client,
current_position,
total_count,
).await?;
match save_outcome {
SaveOutcome::NoChange => "No changes to save.".to_string(),
@@ -81,8 +77,6 @@ pub async fn handle_core_action(
let message = form_revert(
form_state,
grpc_client,
current_position,
total_count,
).await.context("Form revert x action failed")?;
Ok(EventOutcome::Ok(message))
}

View File

@@ -1,20 +1,22 @@
// src/modes/canvas/edit.rs
use crate::config::binds::config::Config;
use crate::functions::modes::edit::{
add_logic_e, add_table_e, auth_e, form_e,
};
use crate::modes::handlers::event::EventHandler;
use crate::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState;
use crate::state::pages::{
auth::{LoginState, RegisterState},
canvas_state::CanvasState,
form::FormState,
};
use crate::state::pages::form::FormState; // <<< ADD THIS LINE
// AddLogicState is already imported
// AddTableState is already imported
use crate::state::pages::admin::AdminState;
use crate::modes::handlers::event::EventOutcome;
use crate::functions::modes::edit::{add_logic_e, auth_e, form_e, add_table_e};
use crate::state::app::state::AppState;
use anyhow::Result;
use crossterm::event::KeyEvent; // Removed KeyCode, KeyModifiers as they were unused
use tracing::debug;
use common::proto::multieko2::search::search_response::Hit;
use crossterm::event::{KeyCode, KeyEvent};
use tokio::sync::mpsc;
use tracing::{debug, info};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditEventOutcome {
@@ -22,231 +24,313 @@ pub enum EditEventOutcome {
ExitEditMode,
}
/// Helper function to spawn a non-blocking search task for autocomplete.
async fn trigger_form_autocomplete_search(
form_state: &mut FormState,
grpc_client: &mut GrpcClient,
sender: mpsc::UnboundedSender<Vec<Hit>>,
) {
if let Some(field_def) = form_state.fields.get(form_state.current_field) {
if field_def.is_link {
if let Some(target_table) = &field_def.link_target_table {
// 1. Update state for immediate UI feedback
form_state.autocomplete_loading = true;
form_state.autocomplete_active = true;
form_state.autocomplete_suggestions.clear();
form_state.selected_suggestion_index = None;
// 2. Clone everything needed for the background task
let query = form_state.get_current_input().to_string();
let table_to_search = target_table.clone();
let mut grpc_client_clone = grpc_client.clone();
info!(
"[Autocomplete] Spawning search in '{}' for query: '{}'",
table_to_search, query
);
// 3. Spawn the non-blocking task
tokio::spawn(async move {
match grpc_client_clone
.search_table(table_to_search, query)
.await
{
Ok(response) => {
// Send results back through the channel
let _ = sender.send(response.hits);
}
Err(e) => {
tracing::error!(
"[Autocomplete] Search failed: {:?}",
e
);
// Send an empty vec on error so the UI can stop loading
let _ = sender.send(vec![]);
}
}
});
}
}
}
}
#[allow(clippy::too_many_arguments)]
pub async fn handle_edit_event(
key: KeyEvent,
config: &Config,
form_state: &mut FormState, // Now FormState is in scope
form_state: &mut FormState,
login_state: &mut LoginState,
register_state: &mut RegisterState,
admin_state: &mut AdminState,
ideal_cursor_column: &mut usize,
current_position: &mut u64,
total_count: u64,
grpc_client: &mut GrpcClient,
event_handler: &mut EventHandler,
app_state: &AppState,
) -> Result<EditEventOutcome> {
// --- Global command mode check ---
if let Some("enter_command_mode") = config.get_action_for_key_in_mode(
&config.keybindings.global, // Assuming command mode can be entered globally
key.code,
key.modifiers,
) {
// This check might be redundant if EventHandler already prevents entering Edit mode
// when command_mode is true. However, it's a safeguard.
return Ok(EditEventOutcome::Message(
"Cannot enter command mode from edit mode here.".to_string(),
));
}
// --- Common actions (save, revert) ---
if let Some(action) = config.get_action_for_key_in_mode(
&config.keybindings.common,
key.code,
key.modifiers,
).as_deref() {
if matches!(action, "save" | "revert") {
let message_string: String = if app_state.ui.show_login {
auth_e::execute_common_action(action, login_state, grpc_client, current_position, total_count).await?
} else if app_state.ui.show_register {
auth_e::execute_common_action(action, register_state, grpc_client, current_position, total_count).await?
} else if app_state.ui.show_add_table {
// TODO: Implement common actions for AddTable if needed
format!("Action '{}' not implemented for Add Table in edit mode.", action)
} else if app_state.ui.show_add_logic {
// TODO: Implement common actions for AddLogic if needed
format!("Action '{}' not implemented for Add Logic in edit mode.", action)
} else { // Assuming Form view
let outcome = form_e::execute_common_action(action, form_state, grpc_client, current_position, total_count).await?;
match outcome {
EventOutcome::Ok(msg) | EventOutcome::DataSaved(_, msg) => msg,
_ => format!("Unexpected outcome from common action: {:?}", outcome),
// --- AUTOCOMPLETE-SPECIFIC KEY HANDLING ---
if app_state.ui.show_form && form_state.autocomplete_active {
if let Some(action) =
config.get_edit_action_for_key(key.code, key.modifiers)
{
match action {
"suggestion_down" => {
if !form_state.autocomplete_suggestions.is_empty() {
let current =
form_state.selected_suggestion_index.unwrap_or(0);
let next = (current + 1)
% form_state.autocomplete_suggestions.len();
form_state.selected_suggestion_index = Some(next);
}
return Ok(EditEventOutcome::Message(String::new()));
}
};
return Ok(EditEventOutcome::Message(message_string));
"suggestion_up" => {
if !form_state.autocomplete_suggestions.is_empty() {
let current =
form_state.selected_suggestion_index.unwrap_or(0);
let prev = if current == 0 {
form_state.autocomplete_suggestions.len() - 1
} else {
current - 1
};
form_state.selected_suggestion_index = Some(prev);
}
return Ok(EditEventOutcome::Message(String::new()));
}
"exit" => {
form_state.deactivate_autocomplete();
return Ok(EditEventOutcome::Message(
"Autocomplete cancelled".to_string(),
));
}
"enter_decider" => {
if let Some(selected_idx) =
form_state.selected_suggestion_index
{
if let Some(selection) = form_state
.autocomplete_suggestions
.get(selected_idx)
.cloned()
{
// --- THIS IS THE CORE LOGIC CHANGE ---
// 1. Get the friendly display name for the UI
let display_name =
form_state.get_display_name_for_hit(&selection);
// 2. Store the REAL ID in the form's values
let current_input =
form_state.get_current_input_mut();
*current_input = selection.id.to_string();
// 3. Set the persistent display override in the map
form_state.link_display_map.insert(
form_state.current_field,
display_name,
);
// 4. Finalize state
form_state.deactivate_autocomplete();
form_state.set_has_unsaved_changes(true);
return Ok(EditEventOutcome::Message(
"Selection made".to_string(),
));
}
}
form_state.deactivate_autocomplete();
// Fall through to default 'enter' behavior
}
_ => {} // Let other keys fall through to the live search logic
}
}
}
// --- Edit-specific actions ---
if let Some(action_str) = config.get_edit_action_for_key(key.code, key.modifiers).as_deref() {
// --- Handle "enter_decider" (Enter key) ---
if action_str == "enter_decider" {
let effective_action = if app_state.ui.show_register
&& register_state.in_suggestion_mode
&& register_state.current_field() == 4 { // Role field
"select_suggestion"
} else if app_state.ui.show_add_logic
&& admin_state.add_logic_state.in_target_column_suggestion_mode
&& admin_state.add_logic_state.current_field() == 1 { // Target Column field
"select_suggestion"
} else {
"next_field" // Default action for Enter
};
// --- LIVE AUTOCOMPLETE TRIGGER LOGIC ---
let mut trigger_search = false;
let msg = if app_state.ui.show_login {
auth_e::execute_edit_action(effective_action, key, login_state, ideal_cursor_column).await?
} else if app_state.ui.show_add_table {
add_table_e::execute_edit_action(effective_action, key, &mut admin_state.add_table_state, ideal_cursor_column).await?
} else if app_state.ui.show_add_logic {
add_logic_e::execute_edit_action(effective_action, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
} else if app_state.ui.show_register {
auth_e::execute_edit_action(effective_action, key, register_state, ideal_cursor_column).await?
} else { // Form view
form_e::execute_edit_action(effective_action, key, form_state, ideal_cursor_column).await?
};
if app_state.ui.show_form {
// Manual trigger
if let Some("trigger_autocomplete") =
config.get_edit_action_for_key(key.code, key.modifiers)
{
if !form_state.autocomplete_active {
trigger_search = true;
}
}
// Live search trigger while typing
else if form_state.autocomplete_active {
if let KeyCode::Char(_) | KeyCode::Backspace = key.code {
let action = if let KeyCode::Backspace = key.code {
"delete_char_backward"
} else {
"insert_char"
};
// FIX: Pass &mut event_handler.ideal_cursor_column
form_e::execute_edit_action(
action,
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?;
trigger_search = true;
}
}
}
if trigger_search {
trigger_form_autocomplete_search(
form_state,
&mut event_handler.grpc_client,
event_handler.autocomplete_result_sender.clone(),
)
.await;
return Ok(EditEventOutcome::Message("Searching...".to_string()));
}
// --- GENERAL EDIT MODE EVENT HANDLING (IF NOT AUTOCOMPLETE) ---
if let Some(action_str) =
config.get_edit_action_for_key(key.code, key.modifiers)
{
// Handle Enter key (next field)
if action_str == "enter_decider" {
// FIX: Pass &mut event_handler.ideal_cursor_column
let msg = form_e::execute_edit_action(
"next_field",
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?;
return Ok(EditEventOutcome::Message(msg));
}
// --- Handle "exit" (Escape key) ---
// Handle exiting edit mode
if action_str == "exit" {
if app_state.ui.show_register && register_state.in_suggestion_mode {
let msg = auth_e::execute_edit_action("exit_suggestion_mode", key, register_state, ideal_cursor_column).await?;
return Ok(EditEventOutcome::Message(msg));
} else if app_state.ui.show_add_logic && admin_state.add_logic_state.in_target_column_suggestion_mode {
admin_state.add_logic_state.in_target_column_suggestion_mode = false;
admin_state.add_logic_state.show_target_column_suggestions = false;
admin_state.add_logic_state.selected_target_column_suggestion_index = None;
return Ok(EditEventOutcome::Message("Exited column suggestions".to_string()));
} else {
return Ok(EditEventOutcome::ExitEditMode);
}
return Ok(EditEventOutcome::ExitEditMode);
}
// --- Autocomplete for AddLogicState Target Column ---
if app_state.ui.show_add_logic && admin_state.add_logic_state.current_field() == 1 { // Target Column field
if action_str == "suggestion_down" { // "Tab" is mapped to suggestion_down
if !admin_state.add_logic_state.in_target_column_suggestion_mode {
// Attempt to open suggestions
if let Some(profile_name) = admin_state.add_logic_state.profile_name.clone().into() {
if let Some(table_name) = admin_state.add_logic_state.selected_table_name.clone() {
debug!("Fetching table structure for autocomplete: Profile='{}', Table='{}'", profile_name, table_name);
match grpc_client.get_table_structure(profile_name, table_name).await {
Ok(ts_response) => {
admin_state.add_logic_state.table_columns_for_suggestions =
ts_response.columns.into_iter().map(|c| c.name).collect();
admin_state.add_logic_state.update_target_column_suggestions();
if !admin_state.add_logic_state.target_column_suggestions.is_empty() {
admin_state.add_logic_state.in_target_column_suggestion_mode = true;
// update_target_column_suggestions handles initial selection
return Ok(EditEventOutcome::Message("Column suggestions shown".to_string()));
} else {
return Ok(EditEventOutcome::Message("No column suggestions for current input".to_string()));
}
}
Err(e) => {
debug!("Error fetching table structure: {}", e);
admin_state.add_logic_state.table_columns_for_suggestions.clear(); // Clear old data on error
admin_state.add_logic_state.update_target_column_suggestions();
return Ok(EditEventOutcome::Message(format!("Error fetching columns: {}", e)));
}
}
} else {
return Ok(EditEventOutcome::Message("No table selected for column suggestions".to_string()));
}
} else { // Should not happen if AddLogic is properly initialized
return Ok(EditEventOutcome::Message("Profile name missing for column suggestions".to_string()));
}
} else { // Already in suggestion mode, navigate down
let msg = add_logic_e::execute_edit_action(action_str, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?;
return Ok(EditEventOutcome::Message(msg));
}
} else if admin_state.add_logic_state.in_target_column_suggestion_mode && action_str == "suggestion_up" {
let msg = add_logic_e::execute_edit_action(action_str, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?;
return Ok(EditEventOutcome::Message(msg));
}
}
// --- Autocomplete for RegisterState Role Field ---
if app_state.ui.show_register && register_state.current_field() == 4 { // Role field
if !register_state.in_suggestion_mode && action_str == "suggestion_down" { // Tab
register_state.update_role_suggestions();
if !register_state.role_suggestions.is_empty() {
register_state.in_suggestion_mode = true;
// update_role_suggestions should handle initial selection
return Ok(EditEventOutcome::Message("Role suggestions shown".to_string()));
} else {
// If Tab doesn't open suggestions, it might fall through to "next_field"
// or you might want specific behavior. For now, let it fall through.
}
}
if register_state.in_suggestion_mode && matches!(action_str, "suggestion_down" | "suggestion_up") {
let msg = auth_e::execute_edit_action(action_str, key, register_state, ideal_cursor_column).await?;
return Ok(EditEventOutcome::Message(msg));
}
}
// --- Dispatch other edit actions ---
// Handle all other edit actions
let msg = if app_state.ui.show_login {
auth_e::execute_edit_action(action_str, key, login_state, ideal_cursor_column).await?
// FIX: Pass &mut event_handler.ideal_cursor_column
auth_e::execute_edit_action(
action_str,
key,
login_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_table {
add_table_e::execute_edit_action(action_str, key, &mut admin_state.add_table_state, ideal_cursor_column).await?
// FIX: Pass &mut event_handler.ideal_cursor_column
add_table_e::execute_edit_action(
action_str,
key,
&mut admin_state.add_table_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_logic {
// If not a suggestion action handled above for AddLogic
if !(admin_state.add_logic_state.in_target_column_suggestion_mode && matches!(action_str, "suggestion_down" | "suggestion_up")) {
add_logic_e::execute_edit_action(action_str, key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
} else { String::new() /* Already handled */ }
// FIX: Pass &mut event_handler.ideal_cursor_column
add_logic_e::execute_edit_action(
action_str,
key,
&mut admin_state.add_logic_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register {
if !(register_state.in_suggestion_mode && matches!(action_str, "suggestion_down" | "suggestion_up")) {
auth_e::execute_edit_action(action_str, key, register_state, ideal_cursor_column).await?
} else { String::new() /* Already handled */ }
} else { // Form view
form_e::execute_edit_action(action_str, key, form_state, ideal_cursor_column).await?
// FIX: Pass &mut event_handler.ideal_cursor_column
auth_e::execute_edit_action(
action_str,
key,
register_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else {
// FIX: Pass &mut event_handler.ideal_cursor_column
form_e::execute_edit_action(
action_str,
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?
};
return Ok(EditEventOutcome::Message(msg));
}
// --- Character insertion ---
// If character insertion happens while in suggestion mode, exit suggestion mode first.
let mut exited_suggestion_mode_for_typing = false;
if app_state.ui.show_register && register_state.in_suggestion_mode {
register_state.in_suggestion_mode = false;
register_state.show_role_suggestions = false;
register_state.selected_suggestion_index = None;
exited_suggestion_mode_for_typing = true;
}
if app_state.ui.show_add_logic && admin_state.add_logic_state.in_target_column_suggestion_mode {
admin_state.add_logic_state.in_target_column_suggestion_mode = false;
admin_state.add_logic_state.show_target_column_suggestions = false;
admin_state.add_logic_state.selected_target_column_suggestion_index = None;
exited_suggestion_mode_for_typing = true;
// --- FALLBACK FOR CHARACTER INSERTION (IF NO OTHER BINDING MATCHED) ---
if let KeyCode::Char(_) = key.code {
let msg = if app_state.ui.show_login {
// FIX: Pass &mut event_handler.ideal_cursor_column
auth_e::execute_edit_action(
"insert_char",
key,
login_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_table {
// FIX: Pass &mut event_handler.ideal_cursor_column
add_table_e::execute_edit_action(
"insert_char",
key,
&mut admin_state.add_table_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_add_logic {
// FIX: Pass &mut event_handler.ideal_cursor_column
add_logic_e::execute_edit_action(
"insert_char",
key,
&mut admin_state.add_logic_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else if app_state.ui.show_register {
// FIX: Pass &mut event_handler.ideal_cursor_column
auth_e::execute_edit_action(
"insert_char",
key,
register_state,
&mut event_handler.ideal_cursor_column,
)
.await?
} else {
// FIX: Pass &mut event_handler.ideal_cursor_column
form_e::execute_edit_action(
"insert_char",
key,
form_state,
&mut event_handler.ideal_cursor_column,
)
.await?
};
return Ok(EditEventOutcome::Message(msg));
}
let mut char_insert_msg = if app_state.ui.show_login {
auth_e::execute_edit_action("insert_char", key, login_state, ideal_cursor_column).await?
} else if app_state.ui.show_add_table {
add_table_e::execute_edit_action("insert_char", key, &mut admin_state.add_table_state, ideal_cursor_column).await?
} else if app_state.ui.show_add_logic {
add_logic_e::execute_edit_action("insert_char", key, &mut admin_state.add_logic_state, ideal_cursor_column).await?
} else if app_state.ui.show_register {
auth_e::execute_edit_action("insert_char", key, register_state, ideal_cursor_column).await?
} else { // Form view
form_e::execute_edit_action("insert_char", key, form_state, ideal_cursor_column).await?
};
// After character insertion, update suggestions if applicable
if app_state.ui.show_register && register_state.current_field() == 4 {
register_state.update_role_suggestions();
// If we just exited suggestion mode by typing, don't immediately show them again unless Tab is pressed.
// However, update_role_suggestions will set show_role_suggestions if matches are found.
// This is fine, as the render logic checks in_suggestion_mode.
}
if app_state.ui.show_add_logic && admin_state.add_logic_state.current_field() == 1 {
admin_state.add_logic_state.update_target_column_suggestions();
}
if exited_suggestion_mode_for_typing && char_insert_msg.is_empty() {
char_insert_msg = "Suggestions hidden".to_string();
}
Ok(EditEventOutcome::Message(char_insert_msg))
Ok(EditEventOutcome::Message(String::new())) // No action taken
}

View File

@@ -23,8 +23,6 @@ pub async fn handle_read_only_event(
add_table_state: &mut AddTableState,
add_logic_state: &mut AddLogicState,
key_sequence_tracker: &mut KeySequenceTracker,
current_position: &mut u64,
total_count: u64,
grpc_client: &mut GrpcClient,
command_message: &mut String,
edit_mode_cooldown: &mut bool,
@@ -74,12 +72,10 @@ pub async fn handle_read_only_event(
action,
form_state,
grpc_client,
current_position,
total_count,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_add_table {
add_table_ro::execute_action(
@@ -143,12 +139,10 @@ pub async fn handle_read_only_event(
action,
form_state,
grpc_client,
current_position,
total_count,
ideal_cursor_column,
)
.await?
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) { // Handle login context actions
} else if app_state.ui.show_login && CONTEXT_ACTIONS_LOGIN.contains(&action) {
crate::tui::functions::login::handle_action(action).await?
} else if app_state.ui.show_add_table {
add_table_ro::execute_action(
@@ -177,7 +171,7 @@ pub async fn handle_read_only_event(
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_login { // Handle login general actions
} else if app_state.ui.show_login {
auth_ro::execute_action(
action,
app_state,
@@ -211,8 +205,6 @@ pub async fn handle_read_only_event(
action,
form_state,
grpc_client,
current_position,
total_count,
ideal_cursor_column,
)
.await?
@@ -245,7 +237,7 @@ pub async fn handle_read_only_event(
key_sequence_tracker,
command_message,
).await?
} else if app_state.ui.show_login { // Handle login general actions
} else if app_state.ui.show_login {
auth_ro::execute_action(
action,
app_state,

View File

@@ -15,7 +15,7 @@ use anyhow::Result;
pub async fn handle_command_event(
key: KeyEvent,
config: &Config,
app_state: &AppState,
app_state: &mut AppState,
login_state: &LoginState,
register_state: &RegisterState,
form_state: &mut FormState,
@@ -74,7 +74,7 @@ pub async fn handle_command_event(
async fn process_command(
config: &Config,
form_state: &mut FormState,
app_state: &AppState,
app_state: &mut AppState,
login_state: &LoginState,
register_state: &RegisterState,
command_input: &mut String,
@@ -117,10 +117,9 @@ async fn process_command(
},
"save" => {
let outcome = save(
app_state,
form_state,
grpc_client,
current_position,
total_count,
).await?;
let message = match outcome {
SaveOutcome::CreatedNew(_) => "New entry created".to_string(),
@@ -134,8 +133,6 @@ async fn process_command(
let message = revert(
form_state,
grpc_client,
current_position,
total_count,
).await?;
command_input.clear();
Ok(EventOutcome::Ok(message))

View File

@@ -1,3 +1,4 @@
// src/client/modes/general.rs
pub mod navigation;
pub mod dialog;
pub mod command_navigation;

View File

@@ -0,0 +1,396 @@
// src/modes/general/command_navigation.rs
use crate::config::binds::config::Config;
use crate::modes::handlers::event::EventOutcome;
use anyhow::Result;
use common::proto::multieko2::table_definition::ProfileTreeResponse;
use crossterm::event::{KeyCode, KeyEvent};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq)]
pub enum NavigationType {
FindFile,
TableTree,
}
#[derive(Debug, Clone)]
pub struct TableDependencyGraph {
all_tables: HashSet<String>,
dependents_map: HashMap<String, Vec<String>>,
root_tables: Vec<String>,
}
impl TableDependencyGraph {
pub fn from_profile_tree(profile_tree: &ProfileTreeResponse) -> Self {
let mut dependents_map: HashMap<String, Vec<String>> = HashMap::new();
let mut all_tables_set: HashSet<String> = HashSet::new();
let mut table_dependencies: HashMap<String, Vec<String>> = HashMap::new();
for profile in &profile_tree.profiles {
for table in &profile.tables {
all_tables_set.insert(table.name.clone());
table_dependencies.insert(table.name.clone(), table.depends_on.clone());
for dependency_name in &table.depends_on {
dependents_map
.entry(dependency_name.clone())
.or_default()
.push(table.name.clone());
}
}
}
let root_tables: Vec<String> = all_tables_set
.iter()
.filter(|name| {
table_dependencies
.get(*name)
.map_or(true, |deps| deps.is_empty())
})
.cloned()
.collect();
let mut sorted_root_tables = root_tables;
sorted_root_tables.sort();
for dependents_list in dependents_map.values_mut() {
dependents_list.sort();
}
Self {
all_tables: all_tables_set,
dependents_map,
root_tables: sorted_root_tables,
}
}
pub fn get_dependent_children(&self, path: &str) -> Vec<String> {
if path.is_empty() {
return self.root_tables.clone();
}
let path_segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if let Some(last_segment_name) = path_segments.last() {
if self.all_tables.contains(*last_segment_name) {
return self
.dependents_map
.get(*last_segment_name)
.cloned()
.unwrap_or_default();
}
}
Vec::new()
}
}
// ... (NavigationState struct and its new(), activate_*, deactivate(), add_char(), remove_char(), move_*, autocomplete_selected(), get_display_input() methods are unchanged) ...
pub struct NavigationState {
pub active: bool,
pub input: String,
pub selected_index: Option<usize>,
pub filtered_options: Vec<(usize, String)>,
pub navigation_type: NavigationType,
pub current_path: String,
pub graph: Option<TableDependencyGraph>,
pub all_options: Vec<String>,
}
impl NavigationState {
pub fn new() -> Self {
Self {
active: false,
input: String::new(),
selected_index: None,
filtered_options: Vec::new(),
navigation_type: NavigationType::FindFile,
current_path: String::new(),
graph: None,
all_options: Vec::new(),
}
}
pub fn activate_find_file(&mut self, options: Vec<String>) {
self.active = true;
self.navigation_type = NavigationType::FindFile;
self.all_options = options;
self.input.clear();
self.current_path.clear();
self.graph = None;
self.update_filtered_options();
}
pub fn activate_table_tree(&mut self, graph: TableDependencyGraph) {
self.active = true;
self.navigation_type = NavigationType::TableTree;
self.graph = Some(graph);
self.input.clear();
self.current_path.clear();
self.update_options_for_path();
}
pub fn deactivate(&mut self) {
self.active = false;
self.input.clear();
self.all_options.clear();
self.filtered_options.clear();
self.selected_index = None;
self.current_path.clear();
self.graph = None;
}
pub fn add_char(&mut self, c: char) {
match self.navigation_type {
NavigationType::FindFile => {
self.input.push(c);
self.update_filtered_options();
}
NavigationType::TableTree => {
if c == '/' {
if !self.input.is_empty() {
if self.current_path.is_empty() {
self.current_path = self.input.clone();
} else {
self.current_path.push('/');
self.current_path.push_str(&self.input);
}
self.input.clear();
self.update_options_for_path();
}
} else {
self.input.push(c);
self.update_filtered_options();
}
}
}
}
pub fn remove_char(&mut self) {
match self.navigation_type {
NavigationType::FindFile => {
self.input.pop();
self.update_filtered_options();
}
NavigationType::TableTree => {
if self.input.is_empty() {
if !self.current_path.is_empty() {
if let Some(last_slash_idx) = self.current_path.rfind('/') {
self.input = self.current_path[last_slash_idx + 1..].to_string();
self.current_path = self.current_path[..last_slash_idx].to_string();
} else {
self.input = self.current_path.clone();
self.current_path.clear();
}
self.update_options_for_path();
self.update_filtered_options();
}
} else {
self.input.pop();
self.update_filtered_options();
}
}
}
}
pub fn move_up(&mut self) {
if self.filtered_options.is_empty() {
self.selected_index = None;
return;
}
self.selected_index = match self.selected_index {
Some(0) => Some(self.filtered_options.len() - 1),
Some(current) => Some(current - 1),
None => Some(self.filtered_options.len() - 1),
};
}
pub fn move_down(&mut self) {
if self.filtered_options.is_empty() {
self.selected_index = None;
return;
}
self.selected_index = match self.selected_index {
Some(current) if current >= self.filtered_options.len() - 1 => Some(0),
Some(current) => Some(current + 1),
None => Some(0),
};
}
pub fn get_selected_option_str(&self) -> Option<&str> {
self.selected_index
.and_then(|idx| self.filtered_options.get(idx))
.map(|(_, option_str)| option_str.as_str())
}
pub fn autocomplete_selected(&mut self) {
if let Some(selected_option_str) = self.get_selected_option_str() {
self.input = selected_option_str.to_string();
self.update_filtered_options();
}
}
pub fn get_display_input(&self) -> String {
match self.navigation_type {
NavigationType::FindFile => self.input.clone(),
NavigationType::TableTree => {
if self.current_path.is_empty() {
self.input.clone()
} else {
format!("{}/{}", self.current_path, self.input)
}
}
}
}
// --- START FIX ---
pub fn get_selected_value(&self) -> Option<String> {
match self.navigation_type {
NavigationType::FindFile => {
// Return the highlighted option, not the raw input buffer.
self.get_selected_option_str().map(|s| s.to_string())
}
NavigationType::TableTree => {
self.get_selected_option_str().map(|selected_name| {
if self.current_path.is_empty() {
selected_name.to_string()
} else {
format!("{}/{}", self.current_path, selected_name)
}
})
}
}
}
// --- END FIX ---
fn update_options_for_path(&mut self) {
if let NavigationType::TableTree = self.navigation_type {
if let Some(graph) = &self.graph {
self.all_options = graph.get_dependent_children(&self.current_path);
} else {
self.all_options.clear();
}
}
self.update_filtered_options();
}
fn update_filtered_options(&mut self) {
let filter_text = match self.navigation_type {
NavigationType::FindFile => &self.input,
NavigationType::TableTree => &self.input,
}
.to_lowercase();
if filter_text.is_empty() {
self.filtered_options = self
.all_options
.iter()
.enumerate()
.map(|(i, opt)| (i, opt.clone()))
.collect();
} else {
self.filtered_options = self
.all_options
.iter()
.enumerate()
.filter(|(_, opt)| opt.to_lowercase().contains(&filter_text))
.map(|(i, opt)| (i, opt.clone()))
.collect();
}
if self.filtered_options.is_empty() {
self.selected_index = None;
} else {
self.selected_index = Some(0);
}
}
}
pub async fn handle_command_navigation_event(
navigation_state: &mut NavigationState,
key: KeyEvent,
config: &Config,
) -> Result<EventOutcome> {
if !navigation_state.active {
return Ok(EventOutcome::Ok(String::new()));
}
match key.code {
KeyCode::Esc => {
navigation_state.deactivate();
Ok(EventOutcome::Ok("Navigation cancelled".to_string()))
}
KeyCode::Tab => {
if let Some(selected_opt_str) = navigation_state.get_selected_option_str() {
if navigation_state.input == selected_opt_str {
if navigation_state.navigation_type == NavigationType::TableTree {
let path_before_nav = navigation_state.current_path.clone();
let input_before_nav = navigation_state.input.clone();
navigation_state.add_char('/');
if !(navigation_state.input.is_empty() &&
(navigation_state.current_path != path_before_nav || !navigation_state.all_options.is_empty())) {
if !navigation_state.input.is_empty() && navigation_state.input != input_before_nav {
navigation_state.input = input_before_nav;
if navigation_state.current_path != path_before_nav {
navigation_state.current_path = path_before_nav;
}
navigation_state.update_options_for_path();
}
}
}
} else {
navigation_state.autocomplete_selected();
}
}
Ok(EventOutcome::Ok(String::new()))
}
KeyCode::Backspace => {
navigation_state.remove_char();
Ok(EventOutcome::Ok(String::new()))
}
KeyCode::Char(c) => {
navigation_state.add_char(c);
Ok(EventOutcome::Ok(String::new()))
}
_ => {
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
match action {
"move_up" => {
navigation_state.move_up();
Ok(EventOutcome::Ok(String::new()))
}
"move_down" => {
navigation_state.move_down();
Ok(EventOutcome::Ok(String::new()))
}
"select" => {
if let Some(selected_value) = navigation_state.get_selected_value() {
let outcome = match navigation_state.navigation_type {
// --- START FIX ---
NavigationType::FindFile => {
// The purpose of this palette is to select a table.
// Emit a TableSelected event instead of a generic Ok message.
EventOutcome::TableSelected {
path: selected_value,
}
}
// --- END FIX ---
NavigationType::TableTree => {
EventOutcome::TableSelected {
path: selected_value,
}
}
};
navigation_state.deactivate();
Ok(outcome)
} else {
Ok(EventOutcome::Ok("No selection".to_string()))
}
}
_ => Ok(EventOutcome::Ok(String::new())),
}
} else {
Ok(EventOutcome::Ok(String::new()))
}
}
}
}

View File

@@ -11,6 +11,7 @@ use crate::state::pages::admin::AdminState;
use crate::state::pages::canvas_state::CanvasState;
use crate::ui::handlers::context::UiContext;
use crate::modes::handlers::event::EventOutcome;
use crate::modes::general::command_navigation::{handle_command_navigation_event, NavigationState};
use anyhow::Result;
pub async fn handle_navigation_event(
@@ -25,7 +26,13 @@ pub async fn handle_navigation_event(
command_mode: &mut bool,
command_input: &mut String,
command_message: &mut String,
navigation_state: &mut NavigationState,
) -> Result<EventOutcome> {
// Handle command navigation first if active
if navigation_state.active {
return handle_command_navigation_event(navigation_state, key, config).await;
}
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
match action {
"move_up" => {

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,10 @@ impl ModeManager {
event_handler: &EventHandler,
admin_state: &AdminState,
) -> AppMode {
if event_handler.navigation_state.active {
return AppMode::General;
}
if event_handler.command_mode {
return AppMode::Command;
}
@@ -78,14 +82,14 @@ impl ModeManager {
}
// Mode transition rules
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
!matches!(current_mode, AppMode::Edit) // Can't enter from Edit mode
pub fn can_enter_command_mode(current_mode: AppMode) -> bool {
!matches!(current_mode, AppMode::Edit)
}
pub fn can_enter_edit_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::ReadOnly) // Only from ReadOnly
matches!(current_mode, AppMode::ReadOnly)
}
pub fn can_enter_read_only_mode(current_mode: AppMode) -> bool {
matches!(current_mode, AppMode::Edit | AppMode::Command | AppMode::Highlight)
}

View File

@@ -44,8 +44,6 @@ pub async fn handle_highlight_event(
&mut admin_state.add_table_state,
&mut admin_state.add_logic_state,
key_sequence_tracker,
current_position,
total_count,
grpc_client,
command_message, // Pass the message buffer
edit_mode_cooldown,

View File

@@ -1,101 +1,216 @@
// src/services/grpc_client.rs
use tonic::transport::Channel;
use common::proto::multieko2::adresar::adresar_client::AdresarClient;
use common::proto::multieko2::adresar::{AdresarResponse, PostAdresarRequest, PutAdresarRequest};
use common::proto::multieko2::common::{CountResponse, PositionRequest, Empty};
use common::proto::multieko2::common::Empty;
use common::proto::multieko2::table_structure::table_structure_service_client::TableStructureServiceClient;
// Import the new request type for table structure
use common::proto::multieko2::table_structure::{TableStructureResponse, GetTableStructureRequest};
use common::proto::multieko2::table_structure::{GetTableStructureRequest, TableStructureResponse};
use common::proto::multieko2::table_definition::{
table_definition_client::TableDefinitionClient,
ProfileTreeResponse, PostTableDefinitionRequest, TableDefinitionResponse,
PostTableDefinitionRequest, ProfileTreeResponse, TableDefinitionResponse,
};
use common::proto::multieko2::table_script::{
table_script_client::TableScriptClient,
PostTableScriptRequest, TableScriptResponse,
};
use anyhow::Result;
use common::proto::multieko2::tables_data::{
tables_data_client::TablesDataClient,
GetTableDataByPositionRequest,
GetTableDataResponse,
GetTableDataCountRequest,
PostTableDataRequest, PostTableDataResponse, PutTableDataRequest,
PutTableDataResponse,
};
use common::proto::multieko2::search::{
searcher_client::SearcherClient, SearchRequest, SearchResponse,
};
use anyhow::{Context, Result};
use std::collections::HashMap;
use tonic::transport::Channel;
use prost_types::Value;
#[derive(Clone)]
pub struct GrpcClient {
adresar_client: AdresarClient<Channel>,
table_structure_client: TableStructureServiceClient<Channel>,
table_definition_client: TableDefinitionClient<Channel>,
table_script_client: TableScriptClient<Channel>,
tables_data_client: TablesDataClient<Channel>,
search_client: SearcherClient<Channel>,
}
impl GrpcClient {
pub async fn new() -> Result<Self> {
let adresar_client = AdresarClient::connect("http://[::1]:50051").await?;
let table_structure_client = TableStructureServiceClient::connect("http://[::1]:50051").await?;
let table_definition_client = TableDefinitionClient::connect("http://[::1]:50051").await?;
let table_script_client = TableScriptClient::connect("http://[::1]:50051").await?;
let channel = Channel::from_static("http://[::1]:50051")
.connect()
.await
.context("Failed to create gRPC channel")?;
let table_structure_client =
TableStructureServiceClient::new(channel.clone());
let table_definition_client =
TableDefinitionClient::new(channel.clone());
let table_script_client = TableScriptClient::new(channel.clone());
let tables_data_client = TablesDataClient::new(channel.clone());
let search_client = SearcherClient::new(channel.clone());
Ok(Self {
adresar_client,
table_structure_client,
table_definition_client,
table_script_client,
tables_data_client,
search_client,
})
}
pub async fn get_adresar_count(&mut self) -> Result<u64> {
let request = tonic::Request::new(Empty::default());
let response: CountResponse = self.adresar_client.get_adresar_count(request).await?.into_inner();
Ok(response.count as u64)
}
pub async fn get_adresar_by_position(&mut self, position: u64) -> Result<AdresarResponse> {
let request = tonic::Request::new(PositionRequest { position: position as i64 });
let response: AdresarResponse = self.adresar_client.get_adresar_by_position(request).await?.into_inner();
Ok(response)
}
pub async fn post_adresar(&mut self, request: PostAdresarRequest) -> Result<tonic::Response<AdresarResponse>> {
let request = tonic::Request::new(request);
let response = self.adresar_client.post_adresar(request).await?;
Ok(response)
}
pub async fn put_adresar(&mut self, request: PutAdresarRequest) -> Result<tonic::Response<AdresarResponse>> {
let request = tonic::Request::new(request);
let response = self.adresar_client.put_adresar(request).await?;
Ok(response)
}
// Updated get_table_structure method
pub async fn get_table_structure(
&mut self,
profile_name: String,
table_name: String,
) -> Result<TableStructureResponse> {
// Create the new request type
let grpc_request = GetTableStructureRequest {
profile_name,
table_name,
};
let request = tonic::Request::new(grpc_request);
// Call the new gRPC method
let response = self.table_structure_client.get_table_structure(request).await?;
let response = self
.table_structure_client
.get_table_structure(request)
.await
.context("gRPC GetTableStructure call failed")?;
Ok(response.into_inner())
}
pub async fn get_profile_tree(&mut self) -> Result<ProfileTreeResponse> {
pub async fn get_profile_tree(
&mut self,
) -> Result<ProfileTreeResponse> {
let request = tonic::Request::new(Empty::default());
let response = self.table_definition_client.get_profile_tree(request).await?;
let response = self
.table_definition_client
.get_profile_tree(request)
.await
.context("gRPC GetProfileTree call failed")?;
Ok(response.into_inner())
}
pub async fn post_table_definition(&mut self, request: PostTableDefinitionRequest) -> Result<TableDefinitionResponse> {
pub async fn post_table_definition(
&mut self,
request: PostTableDefinitionRequest,
) -> Result<TableDefinitionResponse> {
let tonic_request = tonic::Request::new(request);
let response = self.table_definition_client.post_table_definition(tonic_request).await?;
let response = self
.table_definition_client
.post_table_definition(tonic_request)
.await
.context("gRPC PostTableDefinition call failed")?;
Ok(response.into_inner())
}
pub async fn post_table_script(&mut self, request: PostTableScriptRequest) -> Result<TableScriptResponse> {
pub async fn post_table_script(
&mut self,
request: PostTableScriptRequest,
) -> Result<TableScriptResponse> {
let tonic_request = tonic::Request::new(request);
let response = self.table_script_client.post_table_script(tonic_request).await?;
let response = self
.table_script_client
.post_table_script(tonic_request)
.await
.context("gRPC PostTableScript call failed")?;
Ok(response.into_inner())
}
// NEW Methods for TablesData service
pub async fn get_table_data_count(
&mut self,
profile_name: String,
table_name: String,
) -> Result<u64> {
let grpc_request = GetTableDataCountRequest {
profile_name,
table_name,
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.get_table_data_count(request)
.await
.context("gRPC GetTableDataCount call failed")?;
Ok(response.into_inner().count as u64)
}
pub async fn get_table_data_by_position(
&mut self,
profile_name: String,
table_name: String,
position: i32,
) -> Result<GetTableDataResponse> {
let grpc_request = GetTableDataByPositionRequest {
profile_name,
table_name,
position,
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.get_table_data_by_position(request)
.await
.context("gRPC GetTableDataByPosition call failed")?;
Ok(response.into_inner())
}
pub async fn post_table_data(
&mut self,
profile_name: String,
table_name: String,
// CHANGE THIS: Accept the pre-converted data
data: HashMap<String, Value>,
) -> Result<PostTableDataResponse> {
// The conversion logic is now gone from here.
let grpc_request = PostTableDataRequest {
profile_name,
table_name,
data, // This is now the correct type
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.post_table_data(request)
.await
.context("gRPC PostTableData call failed")?;
Ok(response.into_inner())
}
pub async fn put_table_data(
&mut self,
profile_name: String,
table_name: String,
id: i64,
// CHANGE THIS: Accept the pre-converted data
data: HashMap<String, Value>,
) -> Result<PutTableDataResponse> {
// The conversion logic is now gone from here.
let grpc_request = PutTableDataRequest {
profile_name,
table_name,
id,
data, // This is now the correct type
};
let request = tonic::Request::new(grpc_request);
let response = self
.tables_data_client
.put_table_data(request)
.await
.context("gRPC PutTableData call failed")?;
Ok(response.into_inner())
}
pub async fn search_table(
&mut self,
table_name: String,
query: String,
) -> Result<SearchResponse> {
let request = tonic::Request::new(SearchRequest { table_name, query });
let response = self
.search_client
.search_table(request)
.await?;
Ok(response.into_inner())
}
}

View File

@@ -1,15 +1,100 @@
// src/services/ui_service.rs
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::form::FormState;
use crate::tui::functions::common::form::SaveOutcome;
use crate::state::pages::add_logic::AddLogicState;
use crate::state::app::state::AppState;
use anyhow::{Context, Result};
use crate::state::pages::add_logic::AddLogicState;
use crate::state::pages::form::{FieldDefinition, FormState};
use crate::tui::functions::common::form::SaveOutcome;
use crate::utils::columns::filter_user_columns;
use anyhow::{anyhow, Context, Result};
use std::sync::Arc;
pub struct UiService;
impl UiService {
pub async fn load_table_view(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
profile_name: &str,
table_name: &str,
) -> Result<FormState> {
// 1. & 2. Fetch and Cache Schema - UNCHANGED
let table_structure = grpc_client
.get_table_structure(profile_name.to_string(), table_name.to_string())
.await
.context(format!(
"Failed to get table structure for {}.{}",
profile_name, table_name
))?;
let cache_key = format!("{}.{}", profile_name, table_name);
app_state
.schema_cache
.insert(cache_key, Arc::new(table_structure.clone()));
tracing::info!("Schema for '{}.{}' cached.", profile_name, table_name);
// --- START: FINAL, SIMPLIFIED, CORRECT LOGIC ---
// 3a. Create definitions for REGULAR fields first.
let mut fields: Vec<FieldDefinition> = table_structure
.columns
.iter()
.filter(|col| {
!col.is_primary_key
&& col.name != "deleted"
&& col.name != "created_at"
&& !col.name.ends_with("_id") // Filter out ALL potential links
})
.map(|col| FieldDefinition {
display_name: col.name.clone(),
data_key: col.name.clone(),
is_link: false,
link_target_table: None,
})
.collect();
// 3b. Now, find and APPEND definitions for LINK fields based on the `_id` convention.
let link_fields: Vec<FieldDefinition> = table_structure
.columns
.iter()
.filter(|col| col.name.ends_with("_id")) // Find all foreign key columns
.map(|col| {
// The table we link to is derived from the column name.
// e.g., "test_diacritics_id" -> "test_diacritics"
let target_table_base = col
.name
.strip_suffix("_id")
.unwrap_or(&col.name);
// Find the full table name from the profile tree for display.
// e.g., "test_diacritics" -> "2025_test_diacritics"
let full_target_table_name = app_state
.profile_tree
.profiles
.iter()
.find(|p| p.name == profile_name)
.and_then(|p| p.tables.iter().find(|t| t.name.ends_with(target_table_base)))
.map_or(target_table_base.to_string(), |t| t.name.clone());
FieldDefinition {
display_name: full_target_table_name.clone(),
data_key: col.name.clone(), // The actual FK column name
is_link: true,
link_target_table: Some(full_target_table_name),
}
})
.collect();
fields.extend(link_fields); // Append the link fields to the end
// --- END: FINAL, SIMPLIFIED, CORRECT LOGIC ---
Ok(FormState::new(
profile_name.to_string(),
table_name.to_string(),
fields,
))
}
pub async fn initialize_add_logic_table_data(
grpc_client: &mut GrpcClient,
add_logic_state: &mut AddLogicState,
@@ -82,7 +167,7 @@ impl UiService {
.into_iter()
.map(|col| col.name)
.collect();
Ok(column_names)
Ok(filter_user_columns(column_names))
}
Err(e) => {
tracing::warn!("Failed to fetch columns for {}.{}: {}", profile_name, table_name, e);
@@ -91,112 +176,144 @@ impl UiService {
}
}
pub async fn initialize_app_state(
// REFACTOR THIS FUNCTION
pub async fn initialize_app_state_and_form(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
) -> Result<Vec<String>> {
// Fetch profile tree
let profile_tree = grpc_client.get_profile_tree().await.context("Failed to get profile tree")?;
) -> Result<(String, String, Vec<String>)> {
let profile_tree = grpc_client
.get_profile_tree()
.await
.context("Failed to get profile tree")?;
app_state.profile_tree = profile_tree;
// TODO for general tables and not hardcoded
let default_profile_name = "default".to_string();
let default_table_name = "2025_customer".to_string();
let initial_profile_name = app_state
.profile_tree
.profiles
.first()
.map(|p| p.name.clone())
.unwrap_or_else(|| "default".to_string());
// Fetch table structure for the default table
let table_structure = grpc_client
.get_table_structure(default_profile_name, default_table_name)
.await
.context("Failed to get initial table structure")?;
let initial_table_name = app_state
.profile_tree
.profiles
.first()
.and_then(|p| p.tables.first().map(|t| t.name.clone()))
.unwrap_or_else(|| "2025_company_data1".to_string());
// Extract the column names from the response
let column_names: Vec<String> = table_structure
.columns
.iter()
.map(|col| col.name.clone())
.collect();
app_state.set_current_view_table(
initial_profile_name.clone(),
initial_table_name.clone(),
);
Ok(column_names)
// NOW, just call our new central function. This avoids code duplication.
let form_state = Self::load_table_view(
grpc_client,
app_state,
&initial_profile_name,
&initial_table_name,
)
.await?;
// The field names for the UI are derived from the new form_state
let field_names = form_state.fields.iter().map(|f| f.display_name.clone()).collect();
Ok((initial_profile_name, initial_table_name, field_names))
}
pub async fn initialize_adresar_count(
pub async fn fetch_and_set_table_count(
grpc_client: &mut GrpcClient,
form_state: &mut FormState,
) -> Result<()> {
let total_count = grpc_client
.get_table_data_count(
form_state.profile_name.clone(),
form_state.table_name.clone(),
)
.await
.context(format!(
"Failed to get count for table {}.{}",
form_state.profile_name, form_state.table_name
))?;
form_state.total_count = total_count;
if total_count > 0 {
form_state.current_position = total_count;
} else {
form_state.current_position = 1;
}
Ok(())
}
pub async fn load_table_data_by_position(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
) -> Result<()> {
let total_count = grpc_client.get_adresar_count().await.context("Failed to get adresar count")?;
app_state.update_total_count(total_count);
app_state.update_current_position(total_count.saturating_add(1)); // Start in new entry mode
Ok(())
}
pub async fn update_adresar_count(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
) -> Result<()> {
let total_count = grpc_client.get_adresar_count().await.context("Failed to get adresar by position")?;
app_state.update_total_count(total_count);
Ok(())
}
pub async fn load_adresar_by_position(
grpc_client: &mut GrpcClient,
_app_state: &mut AppState,
form_state: &mut FormState,
position: u64,
) -> Result<String> {
match grpc_client.get_adresar_by_position(position).await {
if form_state.current_position == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) {
form_state.reset_to_empty();
return Ok(format!(
"New entry mode for table {}.{}",
form_state.profile_name, form_state.table_name
));
}
if form_state.total_count == 0 && form_state.current_position == 1 {
form_state.reset_to_empty();
return Ok(format!(
"New entry mode for empty table {}.{}",
form_state.profile_name, form_state.table_name
));
}
match grpc_client
.get_table_data_by_position(
form_state.profile_name.clone(),
form_state.table_name.clone(),
form_state.current_position as i32,
)
.await
{
Ok(response) => {
// Set the ID properly
form_state.id = response.id;
// Update form values dynamically
form_state.values = vec![
response.firma,
response.kz,
response.drc,
response.ulica,
response.psc,
response.mesto,
response.stat,
response.banka,
response.ucet,
response.skladm,
response.ico,
response.kontakt,
response.telefon,
response.skladu,
response.fax,
];
form_state.has_unsaved_changes = false;
Ok(format!("Loaded entry {}", position))
// FIX: Pass the current position as the second argument
form_state.update_from_response(&response.data, form_state.current_position);
Ok(format!(
"Loaded entry {}/{} for table {}.{}",
form_state.current_position,
form_state.total_count,
form_state.profile_name,
form_state.table_name
))
}
Err(e) => {
Ok(format!("Error loading entry: {}", e))
tracing::error!(
"Error loading entry {} for table {}.{}: {}",
form_state.current_position,
form_state.profile_name,
form_state.table_name,
e
);
Err(anyhow::anyhow!(
"Error loading entry {}: {}",
form_state.current_position,
e
))
}
}
}
/// Handles the consequences of a save operation, like updating counts.
pub async fn handle_save_outcome(
save_outcome: SaveOutcome,
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
_grpc_client: &mut GrpcClient,
_app_state: &mut AppState,
form_state: &mut FormState,
) -> Result<()> {
match save_outcome {
SaveOutcome::CreatedNew(new_id) => {
// A new record was created, update the count!
UiService::update_adresar_count(grpc_client, app_state).await?;
// Navigate to the new record (now that count is updated)
app_state.update_current_position(app_state.total_count);
form_state.id = new_id; // Ensure ID is set (might be redundant if save already did it)
form_state.id = new_id;
}
SaveOutcome::UpdatedExisting | SaveOutcome::NoChange => {
// No count update needed for these outcomes
// No action needed
}
}
Ok(())
}
}

View File

@@ -2,4 +2,5 @@
pub mod state;
pub mod buffer;
pub mod search;
pub mod highlight;

View File

@@ -0,0 +1,56 @@
// src/state/app/search.rs
use common::proto::multieko2::search::search_response::Hit;
/// Holds the complete state for the search palette.
pub struct SearchState {
/// The name of the table being searched.
pub table_name: String,
/// The current text entered by the user.
pub input: String,
/// The position of the cursor within the input text.
pub cursor_position: usize,
/// The search results returned from the server.
pub results: Vec<Hit>,
/// The index of the currently selected search result.
pub selected_index: usize,
/// A flag to indicate if a search is currently in progress.
pub is_loading: bool,
}
impl SearchState {
/// Creates a new SearchState for a given table.
pub fn new(table_name: String) -> Self {
Self {
table_name,
input: String::new(),
cursor_position: 0,
results: Vec::new(),
selected_index: 0,
is_loading: false,
}
}
/// Moves the selection to the next item, wrapping around if at the end.
pub fn next_result(&mut self) {
if !self.results.is_empty() {
let next = self.selected_index + 1;
self.selected_index = if next >= self.results.len() {
0 // Wrap to the start
} else {
next
};
}
}
/// Moves the selection to the previous item, wrapping around if at the beginning.
pub fn previous_result(&mut self) {
if !self.results.is_empty() {
self.selected_index = if self.selected_index == 0 {
self.results.len() - 1 // Wrap to the end
} else {
self.selected_index - 1
};
}
}
}

View File

@@ -1,11 +1,19 @@
// src/state/state.rs
// src/state/app/state.rs
use std::env;
use common::proto::multieko2::table_definition::ProfileTreeResponse;
use crate::modes::handlers::mode_manager::AppMode;
use crate::ui::handlers::context::DialogPurpose;
use anyhow::Result;
use common::proto::multieko2::table_definition::ProfileTreeResponse;
// NEW: Import the types we need for the cache
use common::proto::multieko2::table_structure::TableStructureResponse;
use crate::modes::handlers::mode_manager::AppMode;
use crate::state::app::search::SearchState;
use crate::ui::handlers::context::DialogPurpose;
use std::collections::HashMap;
use std::env;
use std::sync::Arc;
#[cfg(feature = "ui-debug")]
use std::time::Instant;
// --- DialogState and UiState are unchanged ---
pub struct DialogState {
pub dialog_show: bool,
pub dialog_title: String,
@@ -26,59 +34,75 @@ pub struct UiState {
pub show_form: bool,
pub show_login: bool,
pub show_register: bool,
pub show_search_palette: bool,
pub focus_outside_canvas: bool,
pub dialog: DialogState,
}
#[cfg(feature = "ui-debug")]
#[derive(Debug, Clone)]
pub struct DebugState {
pub displayed_message: String,
pub is_error: bool,
pub display_start_time: Instant,
}
pub struct AppState {
// Core editor state
pub current_dir: String,
pub total_count: u64,
pub current_position: u64,
pub profile_tree: ProfileTreeResponse,
pub selected_profile: Option<String>,
pub current_mode: AppMode,
pub current_view_profile_name: Option<String>,
pub current_view_table_name: Option<String>,
// NEW: The "Rulebook" cache. We use Arc for efficient sharing.
pub schema_cache: HashMap<String, Arc<TableStructureResponse>>,
pub focused_button_index: usize,
pub pending_table_structure_fetch: Option<(String, String)>,
pub search_state: Option<SearchState>,
// UI preferences
pub ui: UiState,
#[cfg(feature = "ui-debug")]
pub debug_state: Option<DebugState>,
}
impl AppState {
pub fn new() -> Result<Self> {
let current_dir = env::current_dir()?
.to_string_lossy()
.to_string();
let current_dir = env::current_dir()?.to_string_lossy().to_string();
Ok(AppState {
current_dir,
total_count: 0,
current_position: 0,
profile_tree: ProfileTreeResponse::default(),
selected_profile: None,
current_view_profile_name: None,
current_view_table_name: None,
current_mode: AppMode::General,
schema_cache: HashMap::new(), // NEW: Initialize the cache
focused_button_index: 0,
pending_table_structure_fetch: None,
search_state: None,
ui: UiState::default(),
#[cfg(feature = "ui-debug")]
debug_state: None,
})
}
// Existing methods remain unchanged
pub fn update_total_count(&mut self, total_count: u64) {
self.total_count = total_count;
}
pub fn update_current_position(&mut self, current_position: u64) {
self.current_position = current_position;
}
// --- ALL YOUR EXISTING METHODS ARE UNTOUCHED ---
pub fn update_mode(&mut self, mode: AppMode) {
self.current_mode = mode;
}
// Add dialog helper methods
/// Shows a dialog with the given title, message, and buttons.
/// The first button (index 0) is active by default.
pub fn set_current_view_table(&mut self, profile_name: String, table_name: String) {
self.current_view_profile_name = Some(profile_name);
self.current_view_table_name = Some(table_name);
}
pub fn show_dialog(
&mut self,
title: &str,
@@ -96,19 +120,17 @@ impl AppState {
self.ui.focus_outside_canvas = true;
}
/// Shows a dialog specifically for loading states.
pub fn show_loading_dialog(&mut self, title: &str, message: &str) {
self.ui.dialog.dialog_title = title.to_string();
self.ui.dialog.dialog_message = message.to_string();
self.ui.dialog.dialog_buttons.clear(); // No buttons during loading
self.ui.dialog.dialog_buttons.clear();
self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = None; // Purpose is set when loading finishes
self.ui.dialog.purpose = None;
self.ui.dialog.is_loading = true;
self.ui.dialog.dialog_show = true;
self.ui.focus_outside_canvas = true; // Keep focus management consistent
self.ui.focus_outside_canvas = true;
}
/// Updates the content of an existing dialog, typically after loading.
pub fn update_dialog_content(
&mut self,
message: &str,
@@ -118,16 +140,12 @@ impl AppState {
if self.ui.dialog.dialog_show {
self.ui.dialog.dialog_message = message.to_string();
self.ui.dialog.dialog_buttons = buttons;
self.ui.dialog.dialog_active_button_index = 0; // Reset focus
self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = Some(purpose);
self.ui.dialog.is_loading = false; // Loading finished
// Keep dialog_show = true
// Keep focus_outside_canvas = true
self.ui.dialog.is_loading = false;
}
}
/// Hides the dialog and clears its content.
pub fn hide_dialog(&mut self) {
self.ui.dialog.dialog_show = false;
self.ui.dialog.dialog_title.clear();
@@ -136,32 +154,30 @@ impl AppState {
self.ui.dialog.dialog_active_button_index = 0;
self.ui.dialog.purpose = None;
self.ui.focus_outside_canvas = false;
self.ui.dialog.is_loading = false;
}
/// Sets the active button index, wrapping around if necessary.
pub fn next_dialog_button(&mut self) {
if !self.ui.dialog.dialog_buttons.is_empty() {
let next_index = (self.ui.dialog.dialog_active_button_index + 1)
% self.ui.dialog.dialog_buttons.len();
self.ui.dialog.dialog_active_button_index = next_index; // Use new name
self.ui.dialog.dialog_active_button_index = next_index;
}
}
/// Sets the active button index, wrapping around if necessary.
pub fn previous_dialog_button(&mut self) {
if !self.ui.dialog.dialog_buttons.is_empty() {
let len = self.ui.dialog.dialog_buttons.len();
let prev_index =
(self.ui.dialog.dialog_active_button_index + len - 1) % len;
self.ui.dialog.dialog_active_button_index = prev_index; // Use new name
self.ui.dialog.dialog_active_button_index = prev_index;
}
}
/// Gets the label of the currently active button, if any.
pub fn get_active_dialog_button_label(&self) -> Option<&str> {
self.ui.dialog
.dialog_buttons // Use new name
.get(self.ui.dialog.dialog_active_button_index) // Use new name
.dialog_buttons
.get(self.ui.dialog.dialog_active_button_index)
.map(|s| s.as_str())
}
}
@@ -178,13 +194,13 @@ impl Default for UiState {
show_login: false,
show_register: false,
show_buffer_list: true,
show_search_palette: false, // ADDED
focus_outside_canvas: false,
dialog: DialogState::default(),
}
}
}
// Update the Default implementation for DialogState itself
impl Default for DialogState {
fn default() -> Self {
Self {

View File

@@ -1,7 +1,9 @@
// src/state/canvas_state.rs
// src/state/pages/canvas_state.rs
use common::proto::multieko2::search::search_response::Hit;
pub trait CanvasState {
// --- Existing methods (unchanged) ---
fn current_field(&self) -> usize;
fn current_cursor_pos(&self) -> usize;
fn has_unsaved_changes(&self) -> bool;
@@ -9,12 +11,22 @@ pub trait CanvasState {
fn get_current_input(&self) -> &str;
fn get_current_input_mut(&mut self) -> &mut String;
fn fields(&self) -> Vec<&str>;
fn set_current_field(&mut self, index: usize);
fn set_current_cursor_pos(&mut self, pos: usize);
fn set_has_unsaved_changes(&mut self, changed: bool);
// --- Autocomplete Support ---
fn get_suggestions(&self) -> Option<&[String]>;
fn get_selected_suggestion_index(&self) -> Option<usize>;
fn get_rich_suggestions(&self) -> Option<&[Hit]> {
None
}
fn get_display_value_for_field(&self, index: usize) -> &str {
self.inputs()
.get(index)
.map(|s| s.as_str())
.unwrap_or("")
}
fn has_display_override(&self, _index: usize) -> bool {
false
}
}

View File

@@ -1,30 +1,109 @@
// src/state/pages/form.rs
use crate::config::colors::themes::Theme;
use ratatui::layout::Rect;
use ratatui::Frame;
use crate::state::app::highlight::HighlightState;
use crate::state::pages::canvas_state::CanvasState;
use common::proto::multieko2::search::search_response::Hit;
use ratatui::layout::Rect;
use ratatui::Frame;
use std::collections::HashMap;
fn json_value_to_string(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
_ => String::new(),
}
}
#[derive(Debug, Clone)]
pub struct FieldDefinition {
pub display_name: String,
pub data_key: String,
pub is_link: bool,
pub link_target_table: Option<String>,
}
#[derive(Clone)]
pub struct FormState {
pub id: i64,
pub fields: Vec<String>,
pub profile_name: String,
pub table_name: String,
pub total_count: u64,
pub current_position: u64,
pub fields: Vec<FieldDefinition>,
pub values: Vec<String>,
pub current_field: usize,
pub has_unsaved_changes: bool,
pub current_cursor_pos: usize,
pub autocomplete_active: bool,
pub autocomplete_suggestions: Vec<Hit>,
pub selected_suggestion_index: Option<usize>,
pub autocomplete_loading: bool,
pub link_display_map: HashMap<usize, String>,
}
impl FormState {
/// Create a new FormState with dynamic fields.
pub fn new(fields: Vec<String>) -> Self {
let values = vec![String::new(); fields.len()]; // Initialize values for each field
pub fn new(
profile_name: String,
table_name: String,
fields: Vec<FieldDefinition>,
) -> Self {
let values = vec![String::new(); fields.len()];
FormState {
id: 0,
profile_name,
table_name,
total_count: 0,
current_position: 1,
fields,
values,
current_field: 0,
has_unsaved_changes: false,
current_cursor_pos: 0,
autocomplete_active: false,
autocomplete_suggestions: Vec::new(),
selected_suggestion_index: None,
autocomplete_loading: false,
link_display_map: HashMap::new(),
}
}
pub fn get_display_name_for_hit(&self, hit: &Hit) -> String {
if let Ok(content_map) =
serde_json::from_str::<HashMap<String, serde_json::Value>>(
&hit.content_json,
)
{
const IGNORED_KEYS: &[&str] = &["id", "deleted", "created_at"];
let mut keys: Vec<_> = content_map
.keys()
.filter(|k| !IGNORED_KEYS.contains(&k.as_str()))
.cloned()
.collect();
keys.sort();
let values: Vec<_> = keys
.iter()
.map(|key| {
content_map
.get(key)
.map(json_value_to_string)
.unwrap_or_default()
})
.filter(|s| !s.is_empty())
.take(1)
.collect();
let display_part = values.first().cloned().unwrap_or_default();
if display_part.is_empty() {
format!("ID: {}", hit.id)
} else {
format!("{} | ID: {}", display_part, hit.id)
}
} else {
format!("ID: {} (parse error)", hit.id)
}
}
@@ -35,31 +114,40 @@ impl FormState {
theme: &Theme,
is_edit_mode: bool,
highlight_state: &HighlightState,
total_count: u64,
current_position: u64,
) {
let fields: Vec<&str> = self.fields.iter().map(|s| s.as_str()).collect();
let values: Vec<&String> = self.values.iter().collect();
let fields_str_slice: Vec<&str> =
self.fields().iter().map(|s| *s).collect();
let values_str_slice: Vec<&String> = self.values.iter().collect();
crate::components::form::form::render_form(
f,
area,
self,
&fields,
&fields_str_slice,
&self.current_field,
&values,
&values_str_slice,
&self.table_name,
theme,
is_edit_mode,
highlight_state,
total_count,
current_position,
self.total_count,
self.current_position,
);
}
pub fn reset_to_empty(&mut self) {
self.id = 0; // Reset ID to 0 for new entries
self.values.iter_mut().for_each(|v| v.clear()); // Clear all values
self.id = 0;
self.values.iter_mut().for_each(|v| v.clear());
self.current_field = 0;
self.current_cursor_pos = 0;
self.has_unsaved_changes = false;
if self.total_count > 0 {
self.current_position = self.total_count + 1;
} else {
self.current_position = 1;
}
self.deactivate_autocomplete();
self.link_display_map.clear();
}
pub fn get_current_input(&self) -> &str {
@@ -70,20 +158,62 @@ impl FormState {
}
pub fn get_current_input_mut(&mut self) -> &mut String {
self.link_display_map.remove(&self.current_field);
self.values
.get_mut(self.current_field)
.expect("Invalid current_field index")
}
pub fn update_from_response(&mut self, response: common::proto::multieko2::adresar::AdresarResponse) {
self.id = response.id;
self.values = vec![
response.firma, response.kz, response.drc,
response.ulica, response.psc, response.mesto,
response.stat, response.banka, response.ucet,
response.skladm, response.ico, response.kontakt,
response.telefon, response.skladu, response.fax,
];
pub fn update_from_response(
&mut self,
response_data: &HashMap<String, String>,
new_position: u64,
) {
self.values = self
.fields
.iter()
.map(|field_def| {
response_data
.get(&field_def.data_key)
.cloned()
.unwrap_or_default()
})
.collect();
let id_str_opt = response_data
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("id"))
.map(|(_, v)| v);
if let Some(id_str) = id_str_opt {
if let Ok(parsed_id) = id_str.parse::<i64>() {
self.id = parsed_id;
} else {
tracing::error!(
"Failed to parse 'id' field '{}' for table {}.{}",
id_str,
self.profile_name,
self.table_name
);
self.id = 0;
}
} else {
self.id = 0;
}
self.current_position = new_position;
self.has_unsaved_changes = false;
self.current_field = 0;
self.current_cursor_pos = 0;
self.deactivate_autocomplete();
self.link_display_map.clear();
}
pub fn deactivate_autocomplete(&mut self) {
self.autocomplete_active = false;
self.autocomplete_suggestions.clear();
self.selected_suggestion_index = None;
self.autocomplete_loading = false;
}
}
@@ -91,58 +221,69 @@ impl CanvasState for FormState {
fn current_field(&self) -> usize {
self.current_field
}
fn current_cursor_pos(&self) -> usize {
self.current_cursor_pos
}
fn has_unsaved_changes(&self) -> bool {
self.has_unsaved_changes
}
fn inputs(&self) -> Vec<&String> {
self.values.iter().collect()
}
fn get_current_input(&self) -> &str {
self.values
.get(self.current_field)
FormState::get_current_input(self)
}
fn get_current_input_mut(&mut self) -> &mut String {
FormState::get_current_input_mut(self)
}
fn fields(&self) -> Vec<&str> {
self.fields
.iter()
.map(|f| f.display_name.as_str())
.collect()
}
fn set_current_field(&mut self, index: usize) {
if index < self.fields.len() {
self.current_field = index;
}
self.deactivate_autocomplete();
}
fn set_current_cursor_pos(&mut self, pos: usize) {
self.current_cursor_pos = pos;
}
fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_unsaved_changes = changed;
}
fn get_suggestions(&self) -> Option<&[String]> {
None
}
fn get_rich_suggestions(&self) -> Option<&[Hit]> {
if self.autocomplete_active {
Some(&self.autocomplete_suggestions)
} else {
None
}
}
fn get_selected_suggestion_index(&self) -> Option<usize> {
if self.autocomplete_active {
self.selected_suggestion_index
} else {
None
}
}
fn get_display_value_for_field(&self, index: usize) -> &str {
if let Some(display_text) = self.link_display_map.get(&index) {
return display_text.as_str();
}
self.inputs()
.get(index)
.map(|s| s.as_str())
.unwrap_or("")
}
fn get_current_input_mut(&mut self) -> &mut String {
self.values
.get_mut(self.current_field)
.expect("Invalid current_field index")
}
fn fields(&self) -> Vec<&str> {
self.fields.iter().map(|s| s.as_str()).collect()
}
// --- Implement the setter methods ---
fn set_current_field(&mut self, index: usize) {
if index < self.fields.len() { // Basic bounds check
self.current_field = index;
}
}
fn set_current_cursor_pos(&mut self, pos: usize) {
// Optional: Add validation based on current input length if needed
self.current_cursor_pos = pos;
}
fn set_has_unsaved_changes(&mut self, changed: bool) {
self.has_unsaved_changes = changed;
}
// --- Autocomplete Support (Not Used for FormState) ---
fn get_suggestions(&self) -> Option<&[String]> {
None // FormState doesn't provide suggestions
}
fn get_selected_suggestion_index(&self) -> Option<usize> {
None // FormState doesn't have selected suggestions
// --- IMPLEMENT THE NEW TRAIT METHOD ---
fn has_display_override(&self, index: usize) -> bool {
self.link_display_map.contains_key(&index)
}
}

View File

@@ -1,115 +1,156 @@
// src/tui/functions/common/form.rs
use crate::services::grpc_client::GrpcClient;
use crate::state::app::state::AppState; // NEW: Import AppState
use crate::state::pages::form::FormState;
use common::proto::multieko2::adresar::{PostAdresarRequest, PutAdresarRequest};
use anyhow::Result;
use crate::utils::data_converter; // NEW: Import our translator
use anyhow::{anyhow, Context, Result};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaveOutcome {
NoChange, // Nothing needed saving
UpdatedExisting, // An existing record was updated
CreatedNew(i64), // A new record was created (include its new ID)
NoChange,
UpdatedExisting,
CreatedNew(i64),
}
/// Shared logic for saving the current form state
// MODIFIED save function signature and logic
pub async fn save(
app_state: &AppState, // NEW: Pass in AppState
form_state: &mut FormState,
grpc_client: &mut GrpcClient,
current_position: &mut u64,
total_count: u64,
) -> Result<SaveOutcome> { // <-- Return SaveOutcome
) -> Result<SaveOutcome> {
if !form_state.has_unsaved_changes {
return Ok(SaveOutcome::NoChange); // Early exit if no changes
return Ok(SaveOutcome::NoChange);
}
let is_new = *current_position == total_count + 1;
let outcome = if is_new {
let post_request = PostAdresarRequest {
firma: form_state.values[0].clone(),
kz: form_state.values[1].clone(),
drc: form_state.values[2].clone(),
ulica: form_state.values[3].clone(),
psc: form_state.values[4].clone(),
mesto: form_state.values[5].clone(),
stat: form_state.values[6].clone(),
banka: form_state.values[7].clone(),
ucet: form_state.values[8].clone(),
skladm: form_state.values[9].clone(),
ico: form_state.values[10].clone(),
kontakt: form_state.values[11].clone(),
telefon: form_state.values[12].clone(),
skladu: form_state.values[13].clone(),
fax: form_state.values[14].clone(),
};
let response = grpc_client.post_adresar(post_request).await?;
let new_id = response.into_inner().id;
form_state.id = new_id;
SaveOutcome::CreatedNew(new_id) // <-- Return CreatedNew with ID
} else {
let put_request = PutAdresarRequest {
id: form_state.id,
firma: form_state.values[0].clone(),
kz: form_state.values[1].clone(),
drc: form_state.values[2].clone(),
ulica: form_state.values[3].clone(),
psc: form_state.values[4].clone(),
mesto: form_state.values[5].clone(),
stat: form_state.values[6].clone(),
banka: form_state.values[7].clone(),
ucet: form_state.values[8].clone(),
skladm: form_state.values[9].clone(),
ico: form_state.values[10].clone(),
kontakt: form_state.values[11].clone(),
telefon: form_state.values[12].clone(),
skladu: form_state.values[13].clone(),
fax: form_state.values[14].clone(),
};
let _ = grpc_client.put_adresar(put_request).await?;
SaveOutcome::UpdatedExisting
// --- NEW: VALIDATION & CONVERSION STEP ---
let cache_key =
format!("{}.{}", form_state.profile_name, form_state.table_name);
let schema = match app_state.schema_cache.get(&cache_key) {
Some(s) => s,
None => {
return Err(anyhow!(
"Schema for table '{}' not found in cache. Cannot save.",
form_state.table_name
));
}
};
let data_map: HashMap<String, String> = form_state
.fields
.iter()
.zip(form_state.values.iter())
.map(|(field_def, value)| (field_def.data_key.clone(), value.clone()))
.collect();
// Use our new translator. It returns a user-friendly error on failure.
let converted_data =
match data_converter::convert_and_validate_data(&data_map, schema) {
Ok(data) => data,
Err(user_error) => return Err(anyhow!(user_error)),
};
// --- END OF NEW STEP ---
let outcome: SaveOutcome;
let is_new_entry = form_state.id == 0
|| (form_state.total_count > 0
&& form_state.current_position > form_state.total_count)
|| (form_state.total_count == 0 && form_state.current_position == 1);
if is_new_entry {
let response = grpc_client
.post_table_data(
form_state.profile_name.clone(),
form_state.table_name.clone(),
converted_data, // Use the validated & converted data
)
.await
.context("Failed to post new table data")?;
if response.success {
form_state.id = response.inserted_id;
form_state.total_count += 1;
form_state.current_position = form_state.total_count;
outcome = SaveOutcome::CreatedNew(response.inserted_id);
} else {
return Err(anyhow!(
"Server failed to insert data: {}",
response.message
));
}
} else {
if form_state.id == 0 {
return Err(anyhow!(
"Cannot update record: ID is 0, but not classified as new entry."
));
}
let response = grpc_client
.put_table_data(
form_state.profile_name.clone(),
form_state.table_name.clone(),
form_state.id,
converted_data, // Use the validated & converted data
)
.await
.context("Failed to put (update) table data")?;
if response.success {
outcome = SaveOutcome::UpdatedExisting;
} else {
return Err(anyhow!(
"Server failed to update data: {}",
response.message
));
}
}
form_state.has_unsaved_changes = false;
Ok(outcome)
}
/// Discard changes since last save
pub async fn revert(
form_state: &mut FormState,
form_state: &mut FormState, // Takes &mut FormState to update it
grpc_client: &mut GrpcClient,
current_position: &mut u64,
total_count: u64,
) -> Result<String> {
let is_new = *current_position == total_count + 1;
if is_new {
// Clear all fields for new entries
form_state.values.iter_mut().for_each(|v| *v = String::new());
form_state.has_unsaved_changes = false;
if form_state.id == 0 || (form_state.total_count > 0 && form_state.current_position > form_state.total_count) || (form_state.total_count == 0 && form_state.current_position == 1) {
let old_total_count = form_state.total_count; // Preserve for correct new position
form_state.reset_to_empty(); // reset_to_empty will clear values and set id=0
form_state.total_count = old_total_count; // Restore total_count
if form_state.total_count > 0 { // Correctly set current_position for new
form_state.current_position = form_state.total_count + 1;
} else {
form_state.current_position = 1;
}
return Ok("New entry cleared".to_string());
}
let data = grpc_client.get_adresar_by_position(*current_position).await?;
if form_state.current_position == 0 || form_state.current_position > form_state.total_count {
if form_state.total_count > 0 {
form_state.current_position = 1;
} else {
// No records to revert to, effectively a new entry state.
form_state.reset_to_empty();
return Ok("No saved data to revert to; form cleared.".to_string());
}
}
// Update form fields with saved values
form_state.values = vec![
data.firma,
data.kz,
data.drc,
data.ulica,
data.psc,
data.mesto,
data.stat,
data.banka,
data.ucet,
data.skladm,
data.ico,
data.kontakt,
data.telefon,
data.skladu,
data.fax,
];
let response = grpc_client
.get_table_data_by_position(
form_state.profile_name.clone(),
form_state.table_name.clone(),
form_state.current_position as i32,
)
.await
.context(format!(
"Failed to get table data by position {} for table {}.{}",
form_state.current_position,
form_state.profile_name,
form_state.table_name
))?;
form_state.has_unsaved_changes = false;
// FIX: Pass the current position as the second argument
form_state.update_from_response(&response.data, form_state.current_position);
Ok("Changes discarded, reloaded last saved version".to_string())
}

View File

@@ -1,19 +1,15 @@
// src/tui/functions/form.rs
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::form::FormState;
use crate::services::grpc_client::GrpcClient;
use crate::state::pages::canvas_state::CanvasState;
use anyhow::{anyhow, Result};
pub async fn handle_action(
action: &str,
form_state: &mut FormState,
grpc_client: &mut GrpcClient,
current_position: &mut u64,
total_count: u64,
_grpc_client: &mut GrpcClient,
ideal_cursor_column: &mut usize,
) -> Result<String> {
// TODO store unsaved changes without deleting form state values
// First check for unsaved changes in both cases
if form_state.has_unsaved_changes() {
return Ok(
"Unsaved changes. Save (Ctrl+S) or Revert (Ctrl+R) before navigating."
@@ -21,71 +17,29 @@ pub async fn handle_action(
);
}
let total_count = form_state.total_count;
match action {
"previous_entry" => {
let new_position = current_position.saturating_sub(1);
if new_position >= 1 {
*current_position = new_position;
let response = grpc_client.get_adresar_by_position(*current_position).await?;
// Direct field assignments
form_state.id = response.id;
form_state.values = vec![
response.firma, response.kz, response.drc,
response.ulica, response.psc, response.mesto,
response.stat, response.banka, response.ucet,
response.skladm, response.ico, response.kontakt,
response.telefon, response.skladu, response.fax,
];
let current_input = form_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1
} else { 0 };
form_state.current_cursor_pos = std::cmp::min(*ideal_cursor_column, max_cursor_pos);
form_state.has_unsaved_changes = false;
Ok(format!("Loaded form entry {}", *current_position))
} else {
Ok("Already at first form entry".into())
// Only decrement if the current position is greater than the first record.
// This prevents wrapping from 1 to total_count.
// It also correctly handles moving from "New Entry" (total_count + 1) to the last record.
if form_state.current_position > 1 {
form_state.current_position -= 1;
*ideal_cursor_column = 0;
}
}
"next_entry" => {
if *current_position <= total_count {
*current_position += 1;
if *current_position <= total_count {
let response = grpc_client.get_adresar_by_position(*current_position).await?;
// Direct field assignments
form_state.id = response.id;
form_state.values = vec![
response.firma, response.kz, response.drc,
response.ulica, response.psc, response.mesto,
response.stat, response.banka, response.ucet,
response.skladm, response.ico, response.kontakt,
response.telefon, response.skladu, response.fax,
];
let current_input = form_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() {
current_input.len() - 1
} else { 0 };
form_state.current_cursor_pos = std::cmp::min(*ideal_cursor_column, max_cursor_pos);
form_state.has_unsaved_changes = false;
Ok(format!("Loaded form entry {}", *current_position))
} else {
form_state.reset_to_empty();
form_state.current_field = 0;
form_state.current_cursor_pos = 0;
*ideal_cursor_column = 0;
Ok("New form entry mode".into())
}
} else {
Ok("Already at last entry".into())
// Only increment if the current position is not yet at the "New Entry" stage.
// The "New Entry" position is total_count + 1.
// This allows moving from the last record to "New Entry", but stops there.
if form_state.current_position <= total_count {
form_state.current_position += 1;
*ideal_cursor_column = 0;
}
}
_ => Err(anyhow!("Unknown form action: {}", action))
_ => return Err(anyhow!("Unknown form action: {}", action)),
}
}
Ok(String::new())
}

View File

@@ -21,4 +21,3 @@ pub enum DialogPurpose {
// TODO in the future:
// ConfirmQuit,
}

View File

@@ -1,4 +1,4 @@
// src/ui/handlers/rat_state.rs
// client/src/ui/handlers/rat_state.rs
use crossterm::event::{KeyCode, KeyModifiers};
use crate::config::binds::config::Config;
use crate::state::app::state::UiState;

View File

@@ -1,30 +1,38 @@
// src/ui/handlers/render.rs
use crate::components::{
admin::add_logic::render_add_logic,
admin::render_add_table,
auth::{login::render_login, register::render_register},
common::dialog::render_dialog,
common::find_file_palette,
common::search_palette::render_search_palette,
form::form::render_form,
handlers::sidebar::{self, calculate_sidebar_layout},
intro::intro::render_intro,
render_background,
render_buffer_list,
render_command_line,
render_status_line,
intro::intro::render_intro,
handlers::sidebar::{self, calculate_sidebar_layout},
form::form::render_form,
admin::render_add_table,
admin::add_logic::render_add_logic,
auth::{login::render_login, register::render_register},
};
use crate::config::colors::themes::Theme;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::Frame;
use crate::state::pages::form::FormState;
use crate::modes::general::command_navigation::NavigationState;
use crate::state::pages::canvas_state::CanvasState;
use crate::state::app::buffer::BufferState;
use crate::state::app::highlight::HighlightState;
use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState;
use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;
use crate::state::pages::form::FormState;
use crate::state::pages::intro::IntroState;
use crate::state::app::buffer::BufferState;
use crate::state::app::state::AppState;
use crate::state::pages::admin::AdminState;
use crate::state::app::highlight::HighlightState;
use ratatui::{
layout::{Constraint, Direction, Layout},
Frame,
};
#[allow(clippy::too_many_arguments)]
pub fn render_ui(
f: &mut Frame,
form_state: &mut FormState,
@@ -35,56 +43,81 @@ pub fn render_ui(
admin_state: &mut AdminState,
buffer_state: &BufferState,
theme: &Theme,
is_edit_mode: bool,
is_event_handler_edit_mode: bool,
highlight_state: &HighlightState,
total_count: u64,
current_position: u64,
event_handler_command_input: &str,
event_handler_command_mode_active: bool,
event_handler_command_message: &str,
navigation_state: &NavigationState,
current_dir: &str,
command_input: &str,
command_mode: bool,
command_message: &str,
current_fps: f64,
app_state: &AppState,
) {
render_background(f, f.area(), theme);
// Adjust layout based on whether buffer list is shown
let constraints = if app_state.ui.show_buffer_list {
vec![
Constraint::Length(1), // Buffer list
Constraint::Min(1), // Main content
Constraint::Length(1), // Status line
Constraint::Length(1), // Command line
]
// --- START DYNAMIC LAYOUT LOGIC ---
let mut status_line_height = 1;
#[cfg(feature = "ui-debug")]
{
if let Some(debug_state) = &app_state.debug_state {
if debug_state.is_error {
status_line_height = 4;
}
}
}
// --- END DYNAMIC LAYOUT LOGIC ---
const PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT: u16 = 15;
let mut bottom_area_constraints: Vec<Constraint> = vec![Constraint::Length(status_line_height)];
let command_palette_area_height = if navigation_state.active {
1 + PALETTE_OPTIONS_HEIGHT_FOR_LAYOUT
} else if event_handler_command_mode_active {
1
} else {
vec![
Constraint::Min(1), // Main content
Constraint::Length(1), // Status line (no buffer list)
Constraint::Length(1), // Command line
]
0
};
let root = Layout::default()
if command_palette_area_height > 0 {
bottom_area_constraints.push(Constraint::Length(command_palette_area_height));
}
let mut main_layout_constraints = vec![Constraint::Min(1)];
if app_state.ui.show_buffer_list {
main_layout_constraints.insert(0, Constraint::Length(1));
}
main_layout_constraints.extend(bottom_area_constraints);
let root_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.constraints(main_layout_constraints)
.split(f.area());
let mut buffer_list_area = None;
let main_content_area;
let status_line_area;
let command_line_area;
// Assign areas based on layout
if app_state.ui.show_buffer_list {
buffer_list_area = Some(root[0]);
main_content_area = root[1];
status_line_area = root[2];
command_line_area = root[3];
let mut chunk_idx = 0;
let buffer_list_area = if app_state.ui.show_buffer_list {
let area = Some(root_chunks[chunk_idx]);
chunk_idx += 1;
area
} else {
main_content_area = root[0];
status_line_area = root[1];
command_line_area = root[2];
}
None
};
let main_content_area = root_chunks[chunk_idx];
chunk_idx += 1;
let status_line_area = root_chunks[chunk_idx];
chunk_idx += 1;
let command_render_area = if command_palette_area_height > 0 {
if root_chunks.len() > chunk_idx {
Some(root_chunks[chunk_idx])
} else {
None
}
} else {
None
};
if app_state.ui.show_intro {
render_intro(f, intro_state, main_content_area, theme);
@@ -95,7 +128,7 @@ pub fn render_ui(
theme,
register_state,
app_state,
register_state.current_field < 4,
register_state.current_field() < 4,
highlight_state,
);
} else if app_state.ui.show_add_table {
@@ -105,7 +138,7 @@ pub fn render_ui(
theme,
app_state,
&mut admin_state.add_table_state,
login_state.current_field < 3,
is_event_handler_edit_mode,
highlight_state,
);
} else if app_state.ui.show_add_logic {
@@ -115,7 +148,7 @@ pub fn render_ui(
theme,
app_state,
&mut admin_state.add_logic_state,
is_edit_mode, // Pass the general edit mode status
is_event_handler_edit_mode,
highlight_state,
);
} else if app_state.ui.show_login {
@@ -125,7 +158,7 @@ pub fn render_ui(
theme,
login_state,
app_state,
login_state.current_field < 2,
login_state.current_field() < 2,
highlight_state,
);
} else if app_state.ui.show_admin {
@@ -140,70 +173,92 @@ pub fn render_ui(
&app_state.selected_profile,
);
} else if app_state.ui.show_form {
let (sidebar_area, form_area) = calculate_sidebar_layout(
app_state.ui.show_sidebar,
main_content_area
);
let (sidebar_area, form_actual_area) =
calculate_sidebar_layout(app_state.ui.show_sidebar, main_content_area);
if let Some(sidebar_rect) = sidebar_area {
sidebar::render_sidebar(
f,
sidebar_rect,
theme,
&app_state.profile_tree,
&app_state.selected_profile
&app_state.selected_profile,
);
}
// This change makes the form stay stationary when toggling sidebar
let available_width = form_area.width;
let form_constraint = if available_width >= 80 {
// Use main_content_area for centering when enough space
let available_width = form_actual_area.width;
let form_render_area = if available_width >= 80 {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(0),
Constraint::Length(80),
Constraint::Min(0),
])
.split(main_content_area)[1]
.constraints([Constraint::Min(0), Constraint::Length(80), Constraint::Min(0)])
.split(form_actual_area)[1]
} else {
// Use form_area (post sidebar) when limited space
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(0),
Constraint::Length(80.min(available_width)),
Constraint::Length(available_width),
Constraint::Min(0),
])
.split(form_area)[1]
.split(form_actual_area)[1]
};
// Convert fields to &[&str] and values to &[&String]
let fields: Vec<&str> = form_state.fields.iter().map(|s| s.as_str()).collect();
let values: Vec<&String> = form_state.values.iter().collect();
render_form(
form_state.render(
f,
form_constraint,
form_state,
&fields,
&form_state.current_field,
&values,
form_render_area,
theme,
is_edit_mode,
is_event_handler_edit_mode,
highlight_state,
total_count,
current_position,
);
}
// Render buffer list if enabled and area is available
if let Some(area) = buffer_list_area {
if app_state.ui.show_buffer_list {
render_buffer_list(f, area, theme, buffer_state, app_state);
render_buffer_list(f, area, theme, buffer_state, app_state);
}
render_status_line(
f,
status_line_area,
current_dir,
theme,
is_event_handler_edit_mode,
current_fps,
app_state,
);
if let Some(palette_or_command_area) = command_render_area {
if navigation_state.active {
find_file_palette::render_find_file_palette(
f,
palette_or_command_area,
theme,
navigation_state,
);
} else if event_handler_command_mode_active {
render_command_line(
f,
palette_or_command_area,
event_handler_command_input,
true,
theme,
event_handler_command_message,
);
}
}
render_status_line(f, status_line_area, current_dir, theme, is_edit_mode, current_fps);
render_command_line(f, command_line_area, command_input, command_mode, theme, command_message);
// This block now correctly handles drawing popups over any view.
if app_state.ui.show_search_palette {
if let Some(search_state) = &app_state.search_state {
render_search_palette(f, f.area(), theme, search_state);
}
} else if app_state.ui.dialog.dialog_show {
render_dialog(
f,
f.area(),
theme,
&app_state.ui.dialog.dialog_title,
&app_state.ui.dialog.dialog_message,
&app_state.ui.dialog.dialog_buttons,
app_state.ui.dialog.dialog_active_button_index,
app_state.ui.dialog.is_loading,
);
}
}

View File

@@ -9,7 +9,7 @@ use crate::modes::common::commands::CommandHandler;
use crate::modes::handlers::event::{EventHandler, EventOutcome};
use crate::modes::handlers::mode_manager::{AppMode, ModeManager};
use crate::state::pages::canvas_state::CanvasState;
use crate::state::pages::form::FormState;
use crate::state::pages::form::{FormState, FieldDefinition}; // Import FieldDefinition
use crate::state::pages::auth::AuthState;
use crate::state::pages::auth::LoginState;
use crate::state::pages::auth::RegisterState;
@@ -23,42 +23,42 @@ use crate::tui::terminal::{EventReader, TerminalCore};
use crate::ui::handlers::render::render_ui;
use crate::tui::functions::common::login::LoginResult;
use crate::tui::functions::common::register::RegisterResult;
// Removed: use crate::tui::functions::common::add_table::handle_save_table_action;
// Removed: use crate::functions::modes::navigation::add_table_nav::SaveTableResultSender;
use crate::ui::handlers::context::DialogPurpose; // UiContext removed if not used directly
use crate::ui::handlers::context::DialogPurpose;
use crate::tui::functions::common::login;
use crate::tui::functions::common::register;
use std::time::Instant;
use anyhow::{Context, Result};
use crate::utils::columns::filter_user_columns;
use anyhow::{anyhow, Context, Result};
use crossterm::cursor::SetCursorStyle;
use crossterm::event as crossterm_event;
use tracing::{error, info, warn}; // Added warn
use tracing::{error, info, warn};
use tokio::sync::mpsc;
use std::time::{Duration, Instant};
#[cfg(feature = "ui-debug")]
use crate::state::app::state::DebugState;
#[cfg(feature = "ui-debug")]
use crate::utils::debug_logger::pop_next_debug_message;
pub async fn run_ui() -> Result<()> {
let config = Config::load().context("Failed to load configuration")?;
let theme = Theme::from_str(&config.colors.theme);
let mut terminal = TerminalCore::new().context("Failed to initialize terminal")?;
let mut grpc_client = GrpcClient::new().await?;
let mut grpc_client = GrpcClient::new().await.context("Failed to create GrpcClient")?;
let mut command_handler = CommandHandler::new();
// --- Channel for Login Results ---
let (login_result_sender, mut login_result_receiver) =
mpsc::channel::<LoginResult>(1);
let (register_result_sender, mut register_result_receiver) =
mpsc::channel::<RegisterResult>(1);
let (save_table_result_sender, mut save_table_result_receiver) =
mpsc::channel::<Result<String>>(1);
let (save_logic_result_sender, _save_logic_result_receiver) = // Prefixed and removed mut
mpsc::channel::<Result<String>>(1);
let (login_result_sender, mut login_result_receiver) = mpsc::channel::<LoginResult>(1);
let (register_result_sender, mut register_result_receiver) = mpsc::channel::<RegisterResult>(1);
let (save_table_result_sender, mut save_table_result_receiver) = mpsc::channel::<Result<String>>(1);
let (save_logic_result_sender, _save_logic_result_receiver) = mpsc::channel::<Result<String>>(1);
let mut event_handler = EventHandler::new(
login_result_sender.clone(),
register_result_sender.clone(),
save_table_result_sender.clone(),
save_logic_result_sender.clone(),
).await.context("Failed to create event handler")?;
grpc_client.clone(),
)
.await
.context("Failed to create event handler")?;
let event_reader = EventReader::new();
let mut auth_state = AuthState::default();
@@ -69,7 +69,6 @@ pub async fn run_ui() -> Result<()> {
let mut buffer_state = BufferState::default();
let mut app_state = AppState::new().context("Failed to create initial app state")?;
// --- DATA: Load auth data from file at startup ---
let mut auto_logged_in = false;
match load_auth_data() {
Ok(Some(stored_data)) => {
@@ -87,15 +86,42 @@ pub async fn run_ui() -> Result<()> {
error!("Failed to load auth data: {}", e);
}
}
// --- END DATA ---
let column_names =
UiService::initialize_app_state(&mut grpc_client, &mut app_state)
.await.context("Failed to initialize app state from UI service")?;
let mut form_state = FormState::new(column_names);
let (initial_profile, initial_table, initial_columns_from_service) =
UiService::initialize_app_state_and_form(&mut grpc_client, &mut app_state)
.await
.context("Failed to initialize app state and form")?;
UiService::initialize_adresar_count(&mut grpc_client, &mut app_state).await?;
form_state.reset_to_empty();
let initial_field_defs: Vec<FieldDefinition> = filter_user_columns(initial_columns_from_service)
.into_iter()
.map(|col_name| FieldDefinition {
display_name: col_name.clone(),
data_key: col_name,
is_link: false,
link_target_table: None,
})
.collect();
let mut form_state = FormState::new(
initial_profile.clone(),
initial_table.clone(),
initial_field_defs,
);
UiService::fetch_and_set_table_count(&mut grpc_client, &mut form_state)
.await
.context(format!(
"Failed to fetch initial count for table {}.{}",
initial_profile, initial_table
))?;
if form_state.total_count > 0 {
if let Err(e) = UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
event_handler.command_message = format!("Error loading initial data: {}", e);
}
} else {
form_state.reset_to_empty();
}
if auto_logged_in {
buffer_state.history = vec![AppView::Form];
@@ -106,9 +132,173 @@ pub async fn run_ui() -> Result<()> {
let mut last_frame_time = Instant::now();
let mut current_fps = 0.0;
let mut needs_redraw = true;
let mut prev_view_profile_name = app_state.current_view_profile_name.clone();
let mut prev_view_table_name = app_state.current_view_table_name.clone();
let mut table_just_switched = false;
loop {
// --- Synchronize UI View from Active Buffer ---
let position_before_event = form_state.current_position;
let mut event_processed = false;
// --- CHANNEL RECEIVERS ---
// For main search palette
match event_handler.search_result_receiver.try_recv() {
Ok(hits) => {
info!("--- 4. Main loop received message from channel. ---");
if let Some(search_state) = app_state.search_state.as_mut() {
search_state.results = hits;
search_state.is_loading = false;
}
needs_redraw = true;
}
Err(mpsc::error::TryRecvError::Empty) => {
}
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Search result channel disconnected!");
}
}
// --- ADDED: For live form autocomplete ---
match event_handler.autocomplete_result_receiver.try_recv() {
Ok(hits) => {
if form_state.autocomplete_active {
form_state.autocomplete_suggestions = hits;
form_state.autocomplete_loading = false;
if !form_state.autocomplete_suggestions.is_empty() {
form_state.selected_suggestion_index = Some(0);
} else {
form_state.selected_suggestion_index = None;
}
event_handler.command_message = format!("Found {} suggestions.", form_state.autocomplete_suggestions.len());
}
needs_redraw = true;
}
Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Autocomplete result channel disconnected!");
}
}
if app_state.ui.show_search_palette {
needs_redraw = true;
}
if crossterm_event::poll(std::time::Duration::from_millis(1))? {
let event = event_reader.read_event().context("Failed to read terminal event")?;
event_processed = true;
let event_outcome_result = event_handler.handle_event(
event,
&config,
&mut terminal,
&mut command_handler,
&mut form_state,
&mut auth_state,
&mut login_state,
&mut register_state,
&mut intro_state,
&mut admin_state,
&mut buffer_state,
&mut app_state,
).await;
let mut should_exit = false;
match event_outcome_result {
Ok(outcome) => match outcome {
EventOutcome::Ok(message) => {
if !message.is_empty() {
event_handler.command_message = message;
}
}
EventOutcome::Exit(message) => {
event_handler.command_message = message;
should_exit = true;
}
EventOutcome::DataSaved(save_outcome, message) => {
event_handler.command_message = message;
if let Err(e) = UiService::handle_save_outcome(
save_outcome,
&mut grpc_client,
&mut app_state,
&mut form_state,
).await {
event_handler.command_message =
format!("Error handling save outcome: {}", e);
}
}
EventOutcome::ButtonSelected { .. } => {}
EventOutcome::TableSelected { path } => {
let parts: Vec<&str> = path.split('/').collect();
if parts.len() == 2 {
let profile_name = parts[0].to_string();
let table_name = parts[1].to_string();
app_state.set_current_view_table(profile_name, table_name);
buffer_state.update_history(AppView::Form);
event_handler.command_message = format!("Loading table: {}", path);
} else {
event_handler.command_message = format!("Invalid table path: {}", path);
}
}
},
Err(e) => {
event_handler.command_message = format!("Error: {}", e);
}
}
if should_exit {
return Ok(());
}
}
match login_result_receiver.try_recv() {
Ok(result) => {
if login::handle_login_result(result, &mut app_state, &mut auth_state, &mut login_state) {
needs_redraw = true;
}
}
Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Login result channel disconnected unexpectedly.");
}
}
match register_result_receiver.try_recv() {
Ok(result) => {
if register::handle_registration_result(result, &mut app_state, &mut register_state) {
needs_redraw = true;
}
}
Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Register result channel disconnected unexpectedly.");
}
}
match save_table_result_receiver.try_recv() {
Ok(result) => {
app_state.hide_dialog();
match result {
Ok(ref success_message) => {
app_state.show_dialog(
"Save Successful",
success_message,
vec!["OK".to_string()],
DialogPurpose::SaveTableSuccess,
);
admin_state.add_table_state.has_unsaved_changes = false;
}
Err(e) => {
event_handler.command_message = format!("Save failed: {}", e);
}
}
needs_redraw = true;
}
Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Save table result channel disconnected unexpectedly.");
}
}
if let Some(active_view) = buffer_state.get_active_view() {
app_state.ui.show_intro = false;
app_state.ui.show_login = false;
@@ -139,11 +329,11 @@ pub async fn run_ui() -> Result<()> {
admin_state.set_profiles(profile_names);
if admin_state.current_focus == AdminFocus::default() ||
!matches!(admin_state.current_focus,
!matches!(admin_state.current_focus,
AdminFocus::InsideProfilesList |
AdminFocus::Tables | AdminFocus::InsideTablesList |
AdminFocus::Button1 | AdminFocus::Button2 | AdminFocus::Button3) {
admin_state.current_focus = AdminFocus::ProfilesPane;
admin_state.current_focus = AdminFocus::ProfilesPane;
}
if admin_state.profile_list_state.selected().is_none() && !app_state.profile_tree.profiles.is_empty() {
admin_state.profile_list_state.select(Some(0));
@@ -155,20 +345,110 @@ pub async fn run_ui() -> Result<()> {
AppView::Scratch => {}
}
}
// --- End Synchronization ---
// --- Handle Pending Table Structure Fetches ---
if app_state.ui.show_form {
let current_view_profile = app_state.current_view_profile_name.clone();
let current_view_table = app_state.current_view_table_name.clone();
// This condition correctly detects a table switch.
if prev_view_profile_name != current_view_profile
|| prev_view_table_name != current_view_table
{
if let (Some(prof_name), Some(tbl_name)) =
(current_view_profile.as_ref(), current_view_table.as_ref())
{
// --- START OF REFACTORED LOGIC ---
app_state.show_loading_dialog(
"Loading Table",
&format!("Fetching data for {}.{}...", prof_name, tbl_name),
);
needs_redraw = true;
// 1. Call our new, central function. It handles fetching AND caching.
match UiService::load_table_view(
&mut grpc_client,
&mut app_state,
prof_name,
tbl_name,
)
.await
{
Ok(mut new_form_state) => {
// 2. The function succeeded, we have a new FormState.
// Now, fetch its data.
if let Err(e) = UiService::fetch_and_set_table_count(
&mut grpc_client,
&mut new_form_state,
)
.await
{
// Handle count fetching error
app_state.update_dialog_content(
&format!("Error fetching count: {}", e),
vec!["OK".to_string()],
DialogPurpose::LoginFailed, // Or a more appropriate purpose
);
} else if new_form_state.total_count > 0 {
// If there are records, load the first/last one
if let Err(e) = UiService::load_table_data_by_position(
&mut grpc_client,
&mut new_form_state,
)
.await
{
// Handle data loading error
app_state.update_dialog_content(
&format!("Error loading data: {}", e),
vec!["OK".to_string()],
DialogPurpose::LoginFailed, // Or a more appropriate purpose
);
} else {
// Success! Hide the loading dialog.
app_state.hide_dialog();
}
} else {
// No records, so just reset to an empty form.
new_form_state.reset_to_empty();
app_state.hide_dialog();
}
// 3. CRITICAL: Replace the old form_state with the new one.
form_state = new_form_state;
// 4. Update our tracking variables.
prev_view_profile_name = current_view_profile;
prev_view_table_name = current_view_table;
table_just_switched = true;
}
Err(e) => {
// This handles errors from load_table_view (e.g., schema fetch failed)
app_state.update_dialog_content(
&format!("Error loading table: {}", e),
vec!["OK".to_string()],
DialogPurpose::LoginFailed, // Or a more appropriate purpose
);
// Revert the view change in app_state to avoid a loop
app_state.current_view_profile_name =
prev_view_profile_name.clone();
app_state.current_view_table_name =
prev_view_table_name.clone();
}
}
// --- END OF REFACTORED LOGIC ---
}
needs_redraw = true;
}
}
if let Some((profile_name, table_name)) = app_state.pending_table_structure_fetch.take() {
if app_state.ui.show_add_logic {
// Ensure admin_state.add_logic_state matches the pending fetch
if admin_state.add_logic_state.profile_name == profile_name &&
admin_state.add_logic_state.selected_table_name.as_deref() == Some(table_name.as_str()) {
admin_state.add_logic_state.selected_table_name.as_deref() == Some(table_name.as_str()) {
info!("Fetching table structure for {}.{}", profile_name, table_name);
let fetch_message = UiService::initialize_add_logic_table_data(
&mut grpc_client,
&mut admin_state.add_logic_state,
&app_state.profile_tree, // Pass the profile tree
&app_state.profile_tree,
).await.unwrap_or_else(|e| {
error!("Error initializing add_logic_table_data: {}", e);
format!("Error fetching table structure: {}", e)
@@ -196,39 +476,10 @@ pub async fn run_ui() -> Result<()> {
}
}
// --- 3. Draw UI ---
if needs_redraw {
terminal.draw(|f| {
render_ui(
f,
&mut form_state,
&mut auth_state,
&login_state,
&register_state,
&intro_state,
&mut admin_state,
&buffer_state,
&theme,
event_handler.is_edit_mode,
&event_handler.highlight_state,
app_state.total_count,
app_state.current_position,
&app_state.current_dir,
&event_handler.command_input,
event_handler.command_mode,
&event_handler.command_message,
current_fps,
&app_state,
);
}).context("Terminal draw call failed")?;
needs_redraw = false;
}
// --- Handle Pending Column Autocomplete for Table Selection ---
if let Some(table_name) = admin_state.add_logic_state.script_editor_awaiting_column_autocomplete.clone() {
if app_state.ui.show_add_logic {
let profile_name = admin_state.add_logic_state.profile_name.clone();
info!("Fetching columns for table selection: {}.{}", profile_name, table_name);
match UiService::fetch_columns_for_table(&mut grpc_client, &profile_name, &table_name).await {
Ok(columns) => {
@@ -247,208 +498,56 @@ pub async fn run_ui() -> Result<()> {
}
}
// --- Cursor Visibility Logic ---
let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &admin_state);
match current_mode {
AppMode::Edit => { terminal.show_cursor()?; }
AppMode::Highlight => { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; terminal.show_cursor()?; }
AppMode::ReadOnly => {
if !app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; }
else { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; }
terminal.show_cursor().context("Failed to show cursor in ReadOnly mode")?;
}
AppMode::General => {
if app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor()?; }
else { terminal.hide_cursor()?; }
}
AppMode::Command => { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor().context("Failed to show cursor in Command mode")?; }
}
// --- End Cursor Visibility Logic ---
let total_count = app_state.total_count;
let mut current_position = app_state.current_position;
let position_before_event = current_position;
if app_state.ui.dialog.is_loading {
needs_redraw = true;
}
// --- 1. Handle Terminal Events ---
let mut event_outcome_result = Ok(EventOutcome::Ok(String::new()));
let mut event_processed = false;
if crossterm_event::poll(std::time::Duration::from_millis(1))? {
let event = event_reader.read_event().context("Failed to read terminal event")?;
event_processed = true;
event_outcome_result = event_handler.handle_event(
event,
&config,
&mut terminal,
&mut grpc_client,
&mut command_handler,
&mut form_state,
&mut auth_state,
&mut login_state,
&mut register_state,
&mut intro_state,
&mut admin_state,
&mut buffer_state,
&mut app_state,
total_count,
&mut current_position,
).await;
}
if event_processed {
needs_redraw = true;
}
app_state.current_position = current_position;
// --- Check for Login Results from Channel ---
match login_result_receiver.try_recv() {
Ok(result) => {
if login::handle_login_result(result, &mut app_state, &mut auth_state, &mut login_state) {
needs_redraw = true;
}
}
Err(mpsc::error::TryRecvError::Empty) => { /* No message waiting */ }
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Login result channel disconnected unexpectedly.");
}
}
// --- Check for Register Results from Channel ---
match register_result_receiver.try_recv() {
Ok(result) => {
if register::handle_registration_result(result, &mut app_state, &mut register_state) {
needs_redraw = true;
}
}
Err(mpsc::error::TryRecvError::Empty) => { /* No message waiting */ }
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Register result channel disconnected unexpectedly.");
}
}
// --- Check for Save Table Results ---
match save_table_result_receiver.try_recv() {
Ok(result) => {
app_state.hide_dialog();
match result {
Ok(ref success_message) => {
app_state.show_dialog(
"Save Successful",
success_message,
vec!["OK".to_string()],
DialogPurpose::SaveTableSuccess,
);
admin_state.add_table_state.has_unsaved_changes = false;
}
Err(e) => {
event_handler.command_message = format!("Save failed: {}", e);
}
}
needs_redraw = true;
}
Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => {
error!("Save table result channel disconnected unexpectedly.");
}
}
// --- Centralized Consequence Handling ---
let mut should_exit = false;
match event_outcome_result {
Ok(outcome) => match outcome {
EventOutcome::Ok(_message) => {
// Message is often set directly in event_handler.command_message
}
EventOutcome::Exit(message) => {
event_handler.command_message = message;
should_exit = true;
}
EventOutcome::DataSaved(save_outcome, message) => {
event_handler.command_message = message;
if let Err(e) = UiService::handle_save_outcome(
save_outcome,
&mut grpc_client,
&mut app_state,
&mut form_state,
)
.await
{
event_handler.command_message =
format!("Error handling save outcome: {}", e);
}
}
EventOutcome::ButtonSelected { context: _, index: _ } => {
// Handled within event_handler or specific navigation modules
}
},
Err(e) => {
event_handler.command_message = format!("Error: {}", e);
}
}
// --- End Consequence Handling ---
// --- Position Change Handling ---
let position_changed = app_state.current_position != position_before_event;
let current_total_count = app_state.total_count; // Use current total_count
let position_changed = form_state.current_position != position_before_event;
let mut position_logic_needs_redraw = false;
if app_state.ui.show_form {
if app_state.ui.show_form && !table_just_switched {
if position_changed && !event_handler.is_edit_mode {
let current_input = form_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
position_logic_needs_redraw = true;
if app_state.current_position > current_total_count + 1 {
app_state.current_position = current_total_count + 1;
}
if app_state.current_position > current_total_count {
if form_state.current_position > form_state.total_count {
form_state.reset_to_empty();
form_state.current_field = 0;
} else if app_state.current_position >= 1 && app_state.current_position <= current_total_count {
let current_position_to_load = app_state.current_position;
let load_message = UiService::load_adresar_by_position(
&mut grpc_client,
&mut app_state,
&mut form_state,
current_position_to_load,
)
.await.with_context(|| format!("Failed to load adresar by position: {}", current_position_to_load))?;
let current_input_after_load = form_state.get_current_input();
let max_cursor_pos_after_load = if !event_handler.is_edit_mode && !current_input_after_load.is_empty() {
current_input_after_load.len() - 1
} else {
current_input_after_load.len()
};
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos_after_load);
if !load_message.starts_with("Loaded entry") || event_handler.command_message.is_empty() {
event_handler.command_message = load_message;
event_handler.command_message = format!("New entry for {}.{}", form_state.profile_name, form_state.table_name);
} else {
match UiService::load_table_data_by_position(&mut grpc_client, &mut form_state).await {
Ok(load_message) => {
if event_handler.command_message.is_empty() || !load_message.starts_with("Error") {
event_handler.command_message = load_message;
}
}
Err(e) => {
event_handler.command_message = format!("Error loading data: {}", e);
}
}
} else { // current_position is 0 or invalid
app_state.current_position = 1.min(current_total_count + 1);
if app_state.current_position > current_total_count { // Handles empty db case
form_state.reset_to_empty();
form_state.current_field = 0;
}
// If db is not empty, this will trigger load in next iteration if position changed to 1
}
let current_input_after_load_str = form_state.get_current_input();
let current_input_len_after_load = current_input_after_load_str.chars().count();
let max_cursor_pos = if current_input_len_after_load > 0 {
current_input_len_after_load.saturating_sub(1)
} else {
0
};
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
} else if !position_changed && !event_handler.is_edit_mode {
let current_input = form_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
let current_input_str = form_state.get_current_input();
let current_input_len = current_input_str.chars().count();
let max_cursor_pos = if current_input_len > 0 {
current_input_len.saturating_sub(1)
} else {
0
};
form_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
}
} else if app_state.ui.show_register {
if !event_handler.is_edit_mode {
if !event_handler.is_edit_mode {
let current_input = register_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
register_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
}
} else if app_state.ui.show_login {
if !event_handler.is_edit_mode {
if !event_handler.is_edit_mode {
let current_input = login_state.get_current_input();
let max_cursor_pos = if !current_input.is_empty() { current_input.len() - 1 } else { 0 };
login_state.current_cursor_pos = event_handler.ideal_cursor_column.min(max_cursor_pos);
@@ -458,18 +557,78 @@ pub async fn run_ui() -> Result<()> {
if position_logic_needs_redraw {
needs_redraw = true;
}
// --- End Position Change Handling ---
if should_exit {
return Ok(());
if app_state.ui.dialog.is_loading {
needs_redraw = true;
}
#[cfg(feature = "ui-debug")]
{
let can_display_next = match &app_state.debug_state {
Some(current) => current.display_start_time.elapsed() >= Duration::from_secs(2),
None => true,
};
if can_display_next {
if let Some((new_message, is_error)) = pop_next_debug_message() {
app_state.debug_state = Some(DebugState {
displayed_message: new_message,
is_error,
display_start_time: Instant::now(),
});
}
}
}
if event_processed || needs_redraw || position_changed {
let current_mode = ModeManager::derive_mode(&app_state, &event_handler, &admin_state);
match current_mode {
AppMode::Edit => { terminal.show_cursor()?; }
AppMode::Highlight => { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; terminal.show_cursor()?; }
AppMode::ReadOnly => {
if !app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyBlock)?; }
else { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; }
terminal.show_cursor().context("Failed to show cursor in ReadOnly mode")?;
}
AppMode::General => {
if app_state.ui.focus_outside_canvas { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor()?; }
else { terminal.hide_cursor()?; }
}
AppMode::Command => { terminal.set_cursor_style(SetCursorStyle::SteadyUnderScore)?; terminal.show_cursor().context("Failed to show cursor in Command mode")?; }
}
terminal.draw(|f| {
render_ui(
f,
&mut form_state,
&mut auth_state,
&login_state,
&register_state,
&intro_state,
&mut admin_state,
&buffer_state,
&theme,
event_handler.is_edit_mode,
&event_handler.highlight_state,
&event_handler.command_input,
event_handler.command_mode,
&event_handler.command_message,
&event_handler.navigation_state,
&app_state.current_dir,
current_fps,
&app_state,
);
}).context("Terminal draw call failed")?;
needs_redraw = false;
}
// --- FPS Calculation ---
let now = Instant::now();
let frame_duration = now.duration_since(last_frame_time);
last_frame_time = now;
if frame_duration.as_secs_f64() > 1e-6 { // Avoid division by zero
if frame_duration.as_secs_f64() > 1e-6 {
current_fps = 1.0 / frame_duration.as_secs_f64();
}
} // End main loop
table_just_switched = false;
}
}

View File

@@ -0,0 +1,14 @@
// src/utils/columns.rs
pub fn is_system_column(column_name: &str) -> bool {
match column_name {
"id" | "deleted" | "created_at" => true,
name if name.ends_with("_id") => true,
_ => false,
}
}
pub fn filter_user_columns(all_columns: Vec<String>) -> Vec<String> {
all_columns.into_iter()
.filter(|col| !is_system_column(col))
.collect()
}

View File

@@ -0,0 +1,50 @@
// src/utils/data_converter.rs
use common::proto::multieko2::table_structure::TableStructureResponse;
use prost_types::{value::Kind, NullValue, Value};
use std::collections::HashMap;
pub fn convert_and_validate_data(
data: &HashMap<String, String>,
schema: &TableStructureResponse,
) -> Result<HashMap<String, Value>, String> {
let type_map: HashMap<_, _> = schema
.columns
.iter()
.map(|col| (col.name.as_str(), col.data_type.as_str()))
.collect();
data.iter()
.map(|(key, str_value)| {
let expected_type = type_map.get(key.as_str()).unwrap_or(&"TEXT");
let kind = if str_value.is_empty() {
// TODO: Use the correct enum variant
Kind::NullValue(NullValue::NullValue.into())
} else {
// Attempt to parse the string based on the expected type
match *expected_type {
"BOOL" => match str_value.to_lowercase().parse::<bool>() {
Ok(v) => Kind::BoolValue(v),
Err(_) => return Err(format!("Invalid boolean for '{}': must be 'true' or 'false'", key)),
},
"INT8" | "INT4" | "INT2" | "SERIAL" | "BIGSERIAL" => {
match str_value.parse::<f64>() {
Ok(v) => Kind::NumberValue(v),
Err(_) => return Err(format!("Invalid number for '{}': must be a whole number", key)),
}
}
"NUMERIC" | "FLOAT4" | "FLOAT8" => match str_value.parse::<f64>() {
Ok(v) => Kind::NumberValue(v),
Err(_) => return Err(format!("Invalid decimal for '{}': must be a number", key)),
},
"TIMESTAMPTZ" | "DATE" | "TIME" | "TEXT" | "VARCHAR" | "UUID" => {
Kind::StringValue(str_value.clone())
}
_ => Kind::StringValue(str_value.clone()),
}
};
Ok((key.clone(), Value { kind: Some(kind) }))
})
.collect()
}

View File

@@ -0,0 +1,46 @@
// client/src/utils/debug_logger.rs
use lazy_static::lazy_static;
use std::collections::VecDeque; // <-- FIX: Import VecDeque
use std::io;
use std::sync::{Arc, Mutex}; // <-- FIX: Import Mutex
lazy_static! {
static ref UI_DEBUG_BUFFER: Arc<Mutex<VecDeque<(String, bool)>>> =
Arc::new(Mutex::new(VecDeque::from([(String::from("Logger initialized..."), false)])));
}
#[derive(Clone)]
pub struct UiDebugWriter;
impl Default for UiDebugWriter {
fn default() -> Self {
Self::new()
}
}
impl UiDebugWriter {
pub fn new() -> Self {
Self
}
}
impl io::Write for UiDebugWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let mut buffer = UI_DEBUG_BUFFER.lock().unwrap();
let message = String::from_utf8_lossy(buf);
let trimmed_message = message.trim().to_string();
let is_error = trimmed_message.starts_with("ERROR");
// Add the new message to the back of the queue
buffer.push_back((trimmed_message, is_error));
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
// A public function to pop the next message from the front of the queue.
pub fn pop_next_debug_message() -> Option<(String, bool)> {
UI_DEBUG_BUFFER.lock().unwrap().pop_front()
}

9
client/src/utils/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
// src/utils/mod.rs
pub mod columns;
pub mod debug_logger;
pub mod data_converter;
pub use columns::*;
pub use debug_logger::*;
pub use data_converter::*;

View File

@@ -5,9 +5,14 @@ edition.workspace = true
license.workspace = true
[dependencies]
prost-types = { workspace = true }
tonic = "0.13.0"
prost = "0.13.5"
serde = { version = "1.0.219", features = ["derive"] }
# Search
tantivy = { workspace = true }
[build-dependencies]
tonic-build = "0.13.0"

View File

@@ -14,6 +14,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
"proto/table_definition.proto",
"proto/tables_data.proto",
"proto/table_script.proto",
"proto/search.proto",
],
&["proto"],
)?;

20
common/proto/search.proto Normal file
View File

@@ -0,0 +1,20 @@
// In common/proto/search.proto
syntax = "proto3";
package multieko2.search;
service Searcher {
rpc SearchTable(SearchRequest) returns (SearchResponse);
}
message SearchRequest {
string table_name = 1;
string query = 2;
}
message SearchResponse {
message Hit {
int64 id = 1; // PostgreSQL row ID
float score = 2;
string content_json = 3;
}
repeated Hit hits = 1;
}

View File

@@ -3,6 +3,7 @@ syntax = "proto3";
package multieko2.tables_data;
import "common.proto";
import "google/protobuf/struct.proto";
service TablesData {
rpc PostTableData (PostTableDataRequest) returns (PostTableDataResponse);
@@ -16,7 +17,7 @@ service TablesData {
message PostTableDataRequest {
string profile_name = 1;
string table_name = 2;
map<string, string> data = 3;
map<string, google.protobuf.Value> data = 3;
}
message PostTableDataResponse {
@@ -29,7 +30,7 @@ message PutTableDataRequest {
string profile_name = 1;
string table_name = 2;
int64 id = 3;
map<string, string> data = 4;
map<string, google.protobuf.Value> data = 4;
}
message PutTableDataResponse {

View File

@@ -1,4 +1,7 @@
// common/src/lib.rs
pub mod search;
pub mod proto {
pub mod multieko2 {
pub mod adresar {
@@ -25,6 +28,9 @@ pub mod proto {
pub mod table_script {
include!("proto/multieko2.table_script.rs");
}
pub mod search {
include!("proto/multieko2.search.rs");
}
pub const FILE_DESCRIPTOR_SET: &[u8] =
include_bytes!("proto/descriptor.bin");
}

Binary file not shown.

View File

@@ -0,0 +1,317 @@
// This file is @generated by prost-build.
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SearchRequest {
#[prost(string, tag = "1")]
pub table_name: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub query: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct SearchResponse {
#[prost(message, repeated, tag = "1")]
pub hits: ::prost::alloc::vec::Vec<search_response::Hit>,
}
/// Nested message and enum types in `SearchResponse`.
pub mod search_response {
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Hit {
/// PostgreSQL row ID
#[prost(int64, tag = "1")]
pub id: i64,
#[prost(float, tag = "2")]
pub score: f32,
#[prost(string, tag = "3")]
pub content_json: ::prost::alloc::string::String,
}
}
/// Generated client implementations.
pub mod searcher_client {
#![allow(
unused_variables,
dead_code,
missing_docs,
clippy::wildcard_imports,
clippy::let_unit_value,
)]
use tonic::codegen::*;
use tonic::codegen::http::Uri;
#[derive(Debug, Clone)]
pub struct SearcherClient<T> {
inner: tonic::client::Grpc<T>,
}
impl SearcherClient<tonic::transport::Channel> {
/// Attempt to create a new client by connecting to a given endpoint.
pub async fn connect<D>(dst: D) -> Result<Self, tonic::transport::Error>
where
D: TryInto<tonic::transport::Endpoint>,
D::Error: Into<StdError>,
{
let conn = tonic::transport::Endpoint::new(dst)?.connect().await?;
Ok(Self::new(conn))
}
}
impl<T> SearcherClient<T>
where
T: tonic::client::GrpcService<tonic::body::Body>,
T::Error: Into<StdError>,
T::ResponseBody: Body<Data = Bytes> + std::marker::Send + 'static,
<T::ResponseBody as Body>::Error: Into<StdError> + std::marker::Send,
{
pub fn new(inner: T) -> Self {
let inner = tonic::client::Grpc::new(inner);
Self { inner }
}
pub fn with_origin(inner: T, origin: Uri) -> Self {
let inner = tonic::client::Grpc::with_origin(inner, origin);
Self { inner }
}
pub fn with_interceptor<F>(
inner: T,
interceptor: F,
) -> SearcherClient<InterceptedService<T, F>>
where
F: tonic::service::Interceptor,
T::ResponseBody: Default,
T: tonic::codegen::Service<
http::Request<tonic::body::Body>,
Response = http::Response<
<T as tonic::client::GrpcService<tonic::body::Body>>::ResponseBody,
>,
>,
<T as tonic::codegen::Service<
http::Request<tonic::body::Body>,
>>::Error: Into<StdError> + std::marker::Send + std::marker::Sync,
{
SearcherClient::new(InterceptedService::new(inner, interceptor))
}
/// Compress requests with the given encoding.
///
/// This requires the server to support it otherwise it might respond with an
/// error.
#[must_use]
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.inner = self.inner.send_compressed(encoding);
self
}
/// Enable decompressing responses.
#[must_use]
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.inner = self.inner.accept_compressed(encoding);
self
}
/// Limits the maximum size of a decoded message.
///
/// Default: `4MB`
#[must_use]
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
self.inner = self.inner.max_decoding_message_size(limit);
self
}
/// Limits the maximum size of an encoded message.
///
/// Default: `usize::MAX`
#[must_use]
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
self.inner = self.inner.max_encoding_message_size(limit);
self
}
pub async fn search_table(
&mut self,
request: impl tonic::IntoRequest<super::SearchRequest>,
) -> std::result::Result<tonic::Response<super::SearchResponse>, tonic::Status> {
self.inner
.ready()
.await
.map_err(|e| {
tonic::Status::unknown(
format!("Service was not ready: {}", e.into()),
)
})?;
let codec = tonic::codec::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static(
"/multieko2.search.Searcher/SearchTable",
);
let mut req = request.into_request();
req.extensions_mut()
.insert(GrpcMethod::new("multieko2.search.Searcher", "SearchTable"));
self.inner.unary(req, path, codec).await
}
}
}
/// Generated server implementations.
pub mod searcher_server {
#![allow(
unused_variables,
dead_code,
missing_docs,
clippy::wildcard_imports,
clippy::let_unit_value,
)]
use tonic::codegen::*;
/// Generated trait containing gRPC methods that should be implemented for use with SearcherServer.
#[async_trait]
pub trait Searcher: std::marker::Send + std::marker::Sync + 'static {
async fn search_table(
&self,
request: tonic::Request<super::SearchRequest>,
) -> std::result::Result<tonic::Response<super::SearchResponse>, tonic::Status>;
}
#[derive(Debug)]
pub struct SearcherServer<T> {
inner: Arc<T>,
accept_compression_encodings: EnabledCompressionEncodings,
send_compression_encodings: EnabledCompressionEncodings,
max_decoding_message_size: Option<usize>,
max_encoding_message_size: Option<usize>,
}
impl<T> SearcherServer<T> {
pub fn new(inner: T) -> Self {
Self::from_arc(Arc::new(inner))
}
pub fn from_arc(inner: Arc<T>) -> Self {
Self {
inner,
accept_compression_encodings: Default::default(),
send_compression_encodings: Default::default(),
max_decoding_message_size: None,
max_encoding_message_size: None,
}
}
pub fn with_interceptor<F>(
inner: T,
interceptor: F,
) -> InterceptedService<Self, F>
where
F: tonic::service::Interceptor,
{
InterceptedService::new(Self::new(inner), interceptor)
}
/// Enable decompressing requests with the given encoding.
#[must_use]
pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.accept_compression_encodings.enable(encoding);
self
}
/// Compress responses with the given encoding, if the client supports it.
#[must_use]
pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self {
self.send_compression_encodings.enable(encoding);
self
}
/// Limits the maximum size of a decoded message.
///
/// Default: `4MB`
#[must_use]
pub fn max_decoding_message_size(mut self, limit: usize) -> Self {
self.max_decoding_message_size = Some(limit);
self
}
/// Limits the maximum size of an encoded message.
///
/// Default: `usize::MAX`
#[must_use]
pub fn max_encoding_message_size(mut self, limit: usize) -> Self {
self.max_encoding_message_size = Some(limit);
self
}
}
impl<T, B> tonic::codegen::Service<http::Request<B>> for SearcherServer<T>
where
T: Searcher,
B: Body + std::marker::Send + 'static,
B::Error: Into<StdError> + std::marker::Send + 'static,
{
type Response = http::Response<tonic::body::Body>;
type Error = std::convert::Infallible;
type Future = BoxFuture<Self::Response, Self::Error>;
fn poll_ready(
&mut self,
_cx: &mut Context<'_>,
) -> Poll<std::result::Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, req: http::Request<B>) -> Self::Future {
match req.uri().path() {
"/multieko2.search.Searcher/SearchTable" => {
#[allow(non_camel_case_types)]
struct SearchTableSvc<T: Searcher>(pub Arc<T>);
impl<T: Searcher> tonic::server::UnaryService<super::SearchRequest>
for SearchTableSvc<T> {
type Response = super::SearchResponse;
type Future = BoxFuture<
tonic::Response<Self::Response>,
tonic::Status,
>;
fn call(
&mut self,
request: tonic::Request<super::SearchRequest>,
) -> Self::Future {
let inner = Arc::clone(&self.0);
let fut = async move {
<T as Searcher>::search_table(&inner, request).await
};
Box::pin(fut)
}
}
let accept_compression_encodings = self.accept_compression_encodings;
let send_compression_encodings = self.send_compression_encodings;
let max_decoding_message_size = self.max_decoding_message_size;
let max_encoding_message_size = self.max_encoding_message_size;
let inner = self.inner.clone();
let fut = async move {
let method = SearchTableSvc(inner);
let codec = tonic::codec::ProstCodec::default();
let mut grpc = tonic::server::Grpc::new(codec)
.apply_compression_config(
accept_compression_encodings,
send_compression_encodings,
)
.apply_max_message_size_config(
max_decoding_message_size,
max_encoding_message_size,
);
let res = grpc.unary(method, req).await;
Ok(res)
};
Box::pin(fut)
}
_ => {
Box::pin(async move {
let mut response = http::Response::new(
tonic::body::Body::default(),
);
let headers = response.headers_mut();
headers
.insert(
tonic::Status::GRPC_STATUS,
(tonic::Code::Unimplemented as i32).into(),
);
headers
.insert(
http::header::CONTENT_TYPE,
tonic::metadata::GRPC_CONTENT_TYPE,
);
Ok(response)
})
}
}
}
}
impl<T> Clone for SearcherServer<T> {
fn clone(&self) -> Self {
let inner = self.inner.clone();
Self {
inner,
accept_compression_encodings: self.accept_compression_encodings,
send_compression_encodings: self.send_compression_encodings,
max_decoding_message_size: self.max_decoding_message_size,
max_encoding_message_size: self.max_encoding_message_size,
}
}
}
/// Generated gRPC service name
pub const SERVICE_NAME: &str = "multieko2.search.Searcher";
impl<T> tonic::server::NamedService for SearcherServer<T> {
const NAME: &'static str = SERVICE_NAME;
}
}

View File

@@ -5,10 +5,10 @@ pub struct PostTableDataRequest {
pub profile_name: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub table_name: ::prost::alloc::string::String,
#[prost(map = "string, string", tag = "3")]
#[prost(map = "string, message", tag = "3")]
pub data: ::std::collections::HashMap<
::prost::alloc::string::String,
::prost::alloc::string::String,
::prost_types::Value,
>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
@@ -28,10 +28,10 @@ pub struct PutTableDataRequest {
pub table_name: ::prost::alloc::string::String,
#[prost(int64, tag = "3")]
pub id: i64,
#[prost(map = "string, string", tag = "4")]
#[prost(map = "string, message", tag = "4")]
pub data: ::std::collections::HashMap<
::prost::alloc::string::String,
::prost::alloc::string::String,
::prost_types::Value,
>,
}
#[derive(Clone, PartialEq, ::prost::Message)]

78
common/src/search.rs Normal file
View File

@@ -0,0 +1,78 @@
// common/src/search.rs
use tantivy::schema::*;
use tantivy::tokenizer::*;
use tantivy::Index;
/// Creates a hybrid Slovak search schema with optimized prefix fields.
pub fn create_search_schema() -> Schema {
let mut schema_builder = Schema::builder();
schema_builder.add_u64_field("pg_id", INDEXED | STORED);
// FIELD 1: For prefixes (1-4 chars).
let short_prefix_indexing = TextFieldIndexing::default()
.set_tokenizer("slovak_prefix_edge")
.set_index_option(IndexRecordOption::WithFreqsAndPositions);
let short_prefix_options = TextOptions::default()
.set_indexing_options(short_prefix_indexing)
.set_stored();
schema_builder.add_text_field("prefix_edge", short_prefix_options);
// FIELD 2: For the full word.
let full_word_indexing = TextFieldIndexing::default()
.set_tokenizer("slovak_prefix_full")
.set_index_option(IndexRecordOption::WithFreqsAndPositions);
let full_word_options = TextOptions::default()
.set_indexing_options(full_word_indexing)
.set_stored();
schema_builder.add_text_field("prefix_full", full_word_options);
// NGRAM FIELD: For substring matching.
let ngram_field_indexing = TextFieldIndexing::default()
.set_tokenizer("slovak_ngram")
.set_index_option(IndexRecordOption::WithFreqsAndPositions);
let ngram_options = TextOptions::default()
.set_indexing_options(ngram_field_indexing)
.set_stored();
schema_builder.add_text_field("text_ngram", ngram_options);
schema_builder.build()
}
/// Registers all necessary Slovak tokenizers with the index.
///
/// This must be called by ANY process that opens the index
/// to ensure the tokenizers are loaded into memory.
pub fn register_slovak_tokenizers(index: &Index) -> tantivy::Result<()> {
let tokenizer_manager = index.tokenizers();
// TOKENIZER for `prefix_edge`: Edge N-gram (1-4 chars)
let edge_tokenizer =
TextAnalyzer::builder(NgramTokenizer::new(1, 4, true)?)
.filter(RemoveLongFilter::limit(40))
.filter(LowerCaser)
.filter(AsciiFoldingFilter)
.build();
tokenizer_manager.register("slovak_prefix_edge", edge_tokenizer);
// TOKENIZER for `prefix_full`: Simple word tokenizer
let full_tokenizer =
TextAnalyzer::builder(SimpleTokenizer::default())
.filter(RemoveLongFilter::limit(40))
.filter(LowerCaser)
.filter(AsciiFoldingFilter)
.build();
tokenizer_manager.register("slovak_prefix_full", full_tokenizer);
// NGRAM TOKENIZER: For substring matching.
let ngram_tokenizer =
TextAnalyzer::builder(NgramTokenizer::new(3, 3, false)?)
.filter(RemoveLongFilter::limit(40))
.filter(LowerCaser)
.filter(AsciiFoldingFilter)
.build();
tokenizer_manager.register("slovak_ngram", ngram_tokenizer);
Ok(())
}

19
search/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "search"
version.workspace = true
edition.workspace = true
license = "AGPL-3.0-or-later"
[dependencies]
anyhow = { workspace = true }
prost = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tonic = { workspace = true }
tracing = { workspace = true }
tantivy = { workspace = true }
common = { path = "../common" }
tonic-reflection = "0.13.1"
sqlx = { version = "0.8.6", features = ["postgres"] }

302
search/src/lib.rs Normal file
View File

@@ -0,0 +1,302 @@
// src/lib.rs
use std::collections::HashMap;
use std::path::Path;
use tantivy::collector::TopDocs;
use tantivy::query::{
BooleanQuery, BoostQuery, FuzzyTermQuery, Occur, Query, QueryParser,
TermQuery,
};
use tantivy::schema::{IndexRecordOption, Value};
use tantivy::{Index, TantivyDocument, Term};
use tonic::{Request, Response, Status};
use common::proto::multieko2::search::{
search_response::Hit, SearchRequest, SearchResponse,
};
pub use common::proto::multieko2::search::searcher_server::SearcherServer;
use common::proto::multieko2::search::searcher_server::Searcher;
use common::search::register_slovak_tokenizers;
use sqlx::{PgPool, Row};
use tracing::info;
// We need to hold the database pool in our service struct.
pub struct SearcherService {
pub pool: PgPool,
}
// normalize_slovak_text function remains unchanged...
fn normalize_slovak_text(text: &str) -> String {
// ... function content is unchanged ...
text.chars()
.map(|c| match c {
'á' | 'à' | 'â' | 'ä' | 'ă' | 'ā' => 'a',
'Á' | 'À' | 'Â' | 'Ä' | 'Ă' | 'Ā' => 'A',
'é' | 'è' | 'ê' | 'ë' | 'ě' | 'ē' => 'e',
'É' | 'È' | 'Ê' | 'Ë' | 'Ě' | 'Ē' => 'E',
'í' | 'ì' | 'î' | 'ï' | 'ī' => 'i',
'Í' | 'Ì' | 'Î' | 'Ï' | 'Ī' => 'I',
'ó' | 'ò' | 'ô' | 'ö' | 'ō' | 'ő' => 'o',
'Ó' | 'Ò' | 'Ô' | 'Ö' | 'Ō' | 'Ő' => 'O',
'ú' | 'ù' | 'û' | 'ü' | 'ū' | 'ű' => 'u',
'Ú' | 'Ù' | 'Û' | 'Ü' | 'Ū' | 'Ű' => 'U',
'ý' | 'ỳ' | 'ŷ' | 'ÿ' => 'y',
'Ý' | 'Ỳ' | 'Ŷ' | 'Ÿ' => 'Y',
'č' => 'c',
'Č' => 'C',
'ď' => 'd',
'Ď' => 'D',
'ľ' => 'l',
'Ľ' => 'L',
'ň' => 'n',
'Ň' => 'N',
'ř' => 'r',
'Ř' => 'R',
'š' => 's',
'Š' => 'S',
'ť' => 't',
'Ť' => 'T',
'ž' => 'z',
'Ž' => 'Z',
_ => c,
})
.collect()
}
#[tonic::async_trait]
impl Searcher for SearcherService {
async fn search_table(
&self,
request: Request<SearchRequest>,
) -> Result<Response<SearchResponse>, Status> {
let req = request.into_inner();
let table_name = req.table_name;
let query_str = req.query;
// --- MODIFIED LOGIC ---
// If the query is empty, fetch the 5 most recent records.
if query_str.trim().is_empty() {
info!(
"Empty query for table '{}'. Fetching default results.",
table_name
);
let qualified_table = format!("gen.\"{}\"", table_name);
let sql = format!(
"SELECT id, to_jsonb(t) AS data FROM {} t ORDER BY id DESC LIMIT 5",
qualified_table
);
let rows = sqlx::query(&sql)
.fetch_all(&self.pool)
.await
.map_err(|e| {
Status::internal(format!(
"DB query for default results failed: {}",
e
))
})?;
let hits: Vec<Hit> = rows
.into_iter()
.map(|row| {
let id: i64 = row.try_get("id").unwrap_or_default();
let json_data: serde_json::Value =
row.try_get("data").unwrap_or_default();
Hit {
id,
// Score is 0.0 as this is not a relevance-ranked search
score: 0.0,
content_json: json_data.to_string(),
}
})
.collect();
info!("--- SERVER: Successfully processed empty query. Returning {} default hits. ---", hits.len());
return Ok(Response::new(SearchResponse { hits }));
}
// --- END OF MODIFIED LOGIC ---
let index_path = Path::new("./tantivy_indexes").join(&table_name);
if !index_path.exists() {
return Err(Status::not_found(format!(
"No search index found for table '{}'",
table_name
)));
}
let index = Index::open_in_dir(&index_path)
.map_err(|e| Status::internal(format!("Failed to open index: {}", e)))?;
register_slovak_tokenizers(&index).map_err(|e| {
Status::internal(format!("Failed to register Slovak tokenizers: {}", e))
})?;
let reader = index.reader().map_err(|e| {
Status::internal(format!("Failed to create index reader: {}", e))
})?;
let searcher = reader.searcher();
let schema = index.schema();
let pg_id_field = schema.get_field("pg_id").map_err(|_| {
Status::internal("Schema is missing the 'pg_id' field.")
})?;
// --- Query Building Logic (no changes here) ---
let prefix_edge_field = schema.get_field("prefix_edge").unwrap();
let prefix_full_field = schema.get_field("prefix_full").unwrap();
let text_ngram_field = schema.get_field("text_ngram").unwrap();
let normalized_query = normalize_slovak_text(&query_str);
let words: Vec<&str> = normalized_query.split_whitespace().collect();
if words.is_empty() {
return Ok(Response::new(SearchResponse { hits: vec![] }));
}
let mut query_layers: Vec<(Occur, Box<dyn Query>)> = Vec::new();
// ... all your query building layers remain exactly the same ...
// ===============================
// LAYER 1: PREFIX MATCHING (HIGHEST PRIORITY, Boost: 4.0)
// ===============================
{
let mut must_clauses: Vec<(Occur, Box<dyn Query>)> = Vec::new();
for word in &words {
let edge_term =
Term::from_field_text(prefix_edge_field, word);
let full_term =
Term::from_field_text(prefix_full_field, word);
let per_word_query = BooleanQuery::new(vec![
(
Occur::Should,
Box::new(TermQuery::new(
edge_term,
IndexRecordOption::Basic,
)),
),
(
Occur::Should,
Box::new(TermQuery::new(
full_term,
IndexRecordOption::Basic,
)),
),
]);
must_clauses.push((Occur::Must, Box::new(per_word_query) as Box<dyn Query>));
}
if !must_clauses.is_empty() {
let prefix_query = BooleanQuery::new(must_clauses);
let boosted_query =
BoostQuery::new(Box::new(prefix_query), 4.0);
query_layers.push((Occur::Should, Box::new(boosted_query)));
}
}
// ===============================
// LAYER 2: FUZZY MATCHING (HIGH PRIORITY, Boost: 3.0)
// ===============================
{
let last_word = words.last().unwrap();
let fuzzy_term =
Term::from_field_text(prefix_full_field, last_word);
let fuzzy_query = FuzzyTermQuery::new(fuzzy_term, 2, true);
let boosted_query = BoostQuery::new(Box::new(fuzzy_query), 3.0);
query_layers.push((Occur::Should, Box::new(boosted_query)));
}
// ===============================
// LAYER 3: PHRASE MATCHING WITH SLOP (MEDIUM PRIORITY, Boost: 2.0)
// ===============================
if words.len() > 1 {
let slop_parser =
QueryParser::for_index(&index, vec![prefix_full_field]);
let slop_query_str = format!("\"{}\"~3", normalized_query);
if let Ok(slop_query) = slop_parser.parse_query(&slop_query_str) {
let boosted_query = BoostQuery::new(slop_query, 2.0);
query_layers.push((Occur::Should, Box::new(boosted_query)));
}
}
// ===============================
// LAYER 4: NGRAM SUBSTRING MATCHING (LOWEST PRIORITY, Boost: 1.0)
// ===============================
{
let ngram_parser =
QueryParser::for_index(&index, vec![text_ngram_field]);
if let Ok(ngram_query) =
ngram_parser.parse_query(&normalized_query)
{
let boosted_query = BoostQuery::new(ngram_query, 1.0);
query_layers.push((Occur::Should, Box::new(boosted_query)));
}
}
let master_query = BooleanQuery::new(query_layers);
// --- End of Query Building Logic ---
let top_docs = searcher
.search(&master_query, &TopDocs::with_limit(100))
.map_err(|e| Status::internal(format!("Search failed: {}", e)))?;
if top_docs.is_empty() {
return Ok(Response::new(SearchResponse { hits: vec![] }));
}
// --- NEW LOGIC: Fetch from DB and combine results ---
// Step 1: Extract (score, pg_id) from Tantivy results.
let mut scored_ids: Vec<(f32, u64)> = Vec::new();
for (score, doc_address) in top_docs {
let doc: TantivyDocument = searcher.doc(doc_address).map_err(|e| {
Status::internal(format!("Failed to retrieve document: {}", e))
})?;
if let Some(pg_id_value) = doc.get_first(pg_id_field) {
if let Some(pg_id) = pg_id_value.as_u64() {
scored_ids.push((score, pg_id));
}
}
}
// Step 2: Fetch all corresponding rows from Postgres in a single query.
let pg_ids: Vec<i64> =
scored_ids.iter().map(|(_, id)| *id as i64).collect();
let qualified_table = format!("gen.\"{}\"", table_name);
let query_str = format!(
"SELECT id, to_jsonb(t) AS data FROM {} t WHERE id = ANY($1)",
qualified_table
);
let rows = sqlx::query(&query_str)
.bind(&pg_ids)
.fetch_all(&self.pool)
.await
.map_err(|e| {
Status::internal(format!("Database query failed: {}", e))
})?;
// Step 3: Map the database results by ID for quick lookup.
let mut content_map: HashMap<i64, String> = HashMap::new();
for row in rows {
let id: i64 = row.try_get("id").unwrap_or(0);
let json_data: serde_json::Value =
row.try_get("data").unwrap_or(serde_json::Value::Null);
content_map.insert(id, json_data.to_string());
}
// Step 4: Build the final response, combining Tantivy scores with PG content.
let hits: Vec<Hit> = scored_ids
.into_iter()
.filter_map(|(score, pg_id)| {
content_map
.get(&(pg_id as i64))
.map(|content_json| Hit {
id: pg_id as i64,
score,
content_json: content_json.clone(),
})
})
.collect();
info!("--- SERVER: Successfully processed search. Returning {} hits. ---", hits.len());
let response = SearchResponse { hits };
Ok(Response::new(response))
}
}

View File

@@ -6,7 +6,11 @@ license = "AGPL-3.0-or-later"
[dependencies]
common = { path = "../common" }
search = { path = "../search" }
anyhow = { workspace = true }
tantivy = { workspace = true }
prost-types = { workspace = true }
chrono = { version = "0.4.40", features = ["serde"] }
dotenvy = "0.15.7"
prost = "0.13.5"
@@ -28,6 +32,7 @@ bcrypt = "0.17.0"
validator = { version = "0.20.0", features = ["derive"] }
uuid = { version = "1.16.0", features = ["serde", "v4"] }
jsonwebtoken = "9.3.1"
rust-stemmers = "1.2.0"
[lib]
name = "server"
@@ -37,3 +42,4 @@ path = "src/lib.rs"
tokio = { version = "1.44", features = ["full", "test-util"] }
rstest = "0.25.0"
lazy_static = "1.5.0"
rand = "0.9.1"

13
server/Makefile Normal file
View File

@@ -0,0 +1,13 @@
# Makefile
test: reset_db run_tests
reset_db:
@echo "Resetting test database..."
@./scripts/reset_test_db.sh
run_tests:
@echo "Running tests..."
@cargo test
.PHONY: test

View File

@@ -1,4 +1,6 @@
-- Main table definitions
CREATE SCHEMA IF NOT EXISTS gen;
CREATE TABLE table_definitions (
id BIGSERIAL PRIMARY KEY,
deleted BOOLEAN NOT NULL DEFAULT FALSE,

View File

@@ -0,0 +1,9 @@
#!/bin/bash
# scripts/reset_test_db.sh
DATABASE_URL=${TEST_DATABASE_URL:-"postgres://multi_psql_dev:3@localhost:5432/multi_rust_test"}
echo "Reset db script"
yes | sqlx database drop --database-url "$DATABASE_URL"
sqlx database create --database-url "$DATABASE_URL"
echo "Test database reset complete."

139
server/src/indexer.rs Normal file
View File

@@ -0,0 +1,139 @@
// server/src/indexer.rs
use sqlx::{PgPool, Row};
use tantivy::schema::Term;
use tantivy::{doc, IndexWriter};
use tokio::sync::mpsc::Receiver;
use tracing::{error, info, warn};
use tantivy::schema::Schema;
use crate::search_schema;
const INDEX_DIR: &str = "./tantivy_indexes";
/// Defines the commands that can be sent to the indexer task.
#[derive(Debug)]
pub enum IndexCommand {
/// Add a new document or update an existing one.
AddOrUpdate(IndexCommandData),
/// Remove a document from the index.
Delete(IndexCommandData),
}
#[derive(Debug)]
pub struct IndexCommandData {
pub table_name: String,
pub row_id: i64,
}
/// The main loop for the background indexer task.
pub async fn indexer_task(pool: PgPool, mut receiver: Receiver<IndexCommand>) {
info!("Background indexer task started.");
while let Some(command) = receiver.recv().await {
info!("Indexer received command: {:?}", command);
let result = match command {
IndexCommand::AddOrUpdate(data) => {
handle_add_or_update(&pool, data).await
}
IndexCommand::Delete(data) => handle_delete(&pool, data).await,
};
if let Err(e) = result {
error!("Failed to process index command: {}", e);
}
}
warn!("Indexer channel closed. Task is shutting down.");
}
/// Handles adding or updating a document in a Tantivy index.
async fn handle_add_or_update(
pool: &PgPool,
data: IndexCommandData,
) -> anyhow::Result<()> {
let qualified_table = format!("gen.\"{}\"", data.table_name);
let query_str = format!(
"SELECT to_jsonb(t) AS data FROM {} t WHERE id = $1",
qualified_table
);
let row = sqlx::query(&query_str)
.bind(data.row_id)
.fetch_one(pool)
.await?;
let json_data: serde_json::Value = row.try_get("data")?;
let slovak_text = extract_text_content(&json_data);
let (mut writer, schema) = get_index_writer(&data.table_name)?;
let pg_id_field = schema.get_field("pg_id").unwrap();
let prefix_edge_field = schema.get_field("prefix_edge").unwrap();
let prefix_full_field = schema.get_field("prefix_full").unwrap();
let text_ngram_field = schema.get_field("text_ngram").unwrap();
let id_term = Term::from_field_u64(pg_id_field, data.row_id as u64);
writer.delete_term(id_term);
writer.add_document(doc!(
pg_id_field => data.row_id as u64,
prefix_edge_field => slovak_text.clone(),
prefix_full_field => slovak_text.clone(),
text_ngram_field => slovak_text
))?;
writer.commit()?;
info!(
"Successfully indexed document id:{} for table:{}",
data.row_id, data.table_name
);
Ok(())
}
/// Handles deleting a document from a Tantivy index.
async fn handle_delete(
_pool: &PgPool,
data: IndexCommandData,
) -> anyhow::Result<()> {
let (mut writer, schema) = get_index_writer(&data.table_name)?;
let pg_id_field = schema.get_field("pg_id").unwrap();
let id_term = Term::from_field_u64(pg_id_field, data.row_id as u64);
writer.delete_term(id_term);
writer.commit()?;
info!(
"Successfully deleted document id:{} from table:{}",
data.row_id, data.table_name
);
Ok(())
}
/// Helper to get or create an index and return its writer and schema.
fn get_index_writer(
table_name: &str,
) -> anyhow::Result<(IndexWriter, Schema)> {
let index = search_schema::get_or_create_index(table_name)?;
let schema = index.schema();
let writer = index.writer(100_000_000)?; // 100MB heap
Ok((writer, schema))
}
/// Extract all text content from a JSON object for indexing
fn extract_text_content(json_data: &serde_json::Value) -> String {
let mut full_text = String::new();
if let Some(obj) = json_data.as_object() {
for value in obj.values() {
match value {
serde_json::Value::String(s) => {
full_text.push_str(s);
full_text.push(' ');
}
serde_json::Value::Number(n) => {
full_text.push_str(&n.to_string());
full_text.push(' ');
}
_ => {}
}
}
}
full_text.trim().to_string()
}

View File

@@ -1,6 +1,8 @@
// src/lib.rs
pub mod db;
pub mod auth;
pub mod indexer;
pub mod search_schema;
pub mod server;
pub mod adresar;
pub mod uctovnictvo;

View File

@@ -0,0 +1,26 @@
// server/src/search_schema.rs
use std::path::Path;
use tantivy::Index;
// Re-export the functions from the common crate.
// This makes them available as `crate::search_schema::create_search_schema`, etc.
pub use common::search::{create_search_schema, register_slovak_tokenizers};
/// Gets an existing index or creates a new one.
/// This function now uses the shared logic from the `common` crate.
pub fn get_or_create_index(table_name: &str) -> tantivy::Result<Index> {
let index_path = Path::new("./tantivy_indexes").join(table_name);
std::fs::create_dir_all(&index_path)?;
let index = if index_path.join("meta.json").exists() {
Index::open_in_dir(&index_path)?
} else {
let schema = create_search_schema();
Index::create_in_dir(&index_path, schema)?
};
// This now calls the single, authoritative function from `common`.
register_slovak_tokenizers(&index)?;
Ok(index)
}

View File

@@ -1,7 +1,9 @@
// src/server/run.rs
use tonic::transport::Server;
use tonic_reflection::server::Builder as ReflectionBuilder;
use tokio::sync::mpsc;
use crate::indexer::{indexer_task, IndexCommand};
use common::proto::multieko2::FILE_DESCRIPTOR_SET;
use crate::server::services::{
AdresarService,
@@ -21,23 +23,38 @@ use common::proto::multieko2::{
table_script::table_script_server::TableScriptServer,
auth::auth_service_server::AuthServiceServer
};
use search::{SearcherService, SearcherServer};
pub async fn run_server(db_pool: sqlx::PgPool) -> Result<(), Box<dyn std::error::Error>> {
// Initialize JWT for authentication
crate::auth::logic::jwt::init_jwt()?;
let addr = "[::1]:50051".parse()?;
println!("Unified Server listening on {}", addr);
// 1. Create the MPSC channel for indexer commands
let (indexer_tx, indexer_rx) = mpsc::channel::<IndexCommand>(100); // Buffer of 100 messages
// 2. Spawn the background indexer task
let indexer_pool = db_pool.clone();
tokio::spawn(indexer_task(indexer_pool, indexer_rx));
let reflection_service = ReflectionBuilder::configure()
.register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET)
.build_v1()?;
// Initialize services
// Initialize services, passing the indexer sender to the relevant ones
let table_definition_service = TableDefinitionService { db_pool: db_pool.clone() };
let tables_data_service = TablesDataService { db_pool: db_pool.clone() };
let tables_data_service = TablesDataService {
db_pool: db_pool.clone(),
indexer_tx: indexer_tx.clone(), // Pass the sender
};
let table_script_service = TableScriptService { db_pool: db_pool.clone() };
let auth_service = AuthServiceImpl { db_pool: db_pool.clone() };
// MODIFIED: Instantiate SearcherService with the database pool
let search_service = SearcherService { pool: db_pool.clone() };
Server::builder()
.add_service(AdresarServer::new(AdresarService { db_pool: db_pool.clone() }))
.add_service(UctovnictvoServer::new(UctovnictvoService { db_pool: db_pool.clone() }))
@@ -46,6 +63,7 @@ pub async fn run_server(db_pool: sqlx::PgPool) -> Result<(), Box<dyn std::error:
.add_service(TablesDataServer::new(tables_data_service))
.add_service(TableScriptServer::new(table_script_service))
.add_service(AuthServiceServer::new(auth_service))
.add_service(SearcherServer::new(search_service)) // This now works correctly
.add_service(reflection_service)
.serve(addr)
.await?;

View File

@@ -1,5 +1,10 @@
// src/server/services/tables_data_service.rs
use tonic::{Request, Response, Status};
// Add these imports
use tokio::sync::mpsc;
use crate::indexer::IndexCommand;
use common::proto::multieko2::tables_data::tables_data_server::TablesData;
use common::proto::multieko2::common::CountResponse;
use common::proto::multieko2::tables_data::{
@@ -15,6 +20,8 @@ use sqlx::PgPool;
#[derive(Debug)]
pub struct TablesDataService {
pub db_pool: PgPool,
// MODIFIED: Add the sender field
pub indexer_tx: mpsc::Sender<IndexCommand>,
}
#[tonic::async_trait]
@@ -24,25 +31,34 @@ impl TablesData for TablesDataService {
request: Request<PostTableDataRequest>,
) -> Result<Response<PostTableDataResponse>, Status> {
let request = request.into_inner();
let response = post_table_data(&self.db_pool, request).await?;
// MODIFIED: Pass the indexer_tx to the handler
let response = post_table_data(
&self.db_pool,
request,
&self.indexer_tx,
)
.await?;
Ok(Response::new(response))
}
// Add the new method implementation
// You will later apply the same pattern to put_table_data...
async fn put_table_data(
&self,
request: Request<PutTableDataRequest>,
) -> Result<Response<PutTableDataResponse>, Status> {
let request = request.into_inner();
// TODO: Update put_table_data handler to accept and use indexer_tx
let response = put_table_data(&self.db_pool, request).await?;
Ok(Response::new(response))
}
// ...and delete_table_data
async fn delete_table_data(
&self,
request: Request<DeleteTableDataRequest>,
) -> Result<Response<DeleteTableDataResponse>, Status> {
let request = request.into_inner();
// TODO: Update delete_table_data handler to accept and use indexer_tx
let response = delete_table_data(&self.db_pool, request).await?;
Ok(Response::new(response))
}

View File

@@ -1,2 +1,3 @@
// src/shared/mod.rs
pub mod date_utils;
pub mod schema_qualifier;

View File

@@ -0,0 +1,51 @@
// src/shared/schema_qualifier.rs
use sqlx::PgPool;
use tonic::Status;
// TODO in the future, remove database query on every request and implement caching for scalable
// solution with many data and requests
/// Qualifies a table name by checking for its existence in the table_definitions table.
/// This is the robust, "source of truth" approach.
///
/// Rules:
/// - If a table is found in `table_definitions`, it is qualified with the 'gen' schema.
/// - Otherwise, it is assumed to be a system table in the 'public' schema.
pub async fn qualify_table_name(
db_pool: &PgPool,
profile_name: &str,
table_name: &str,
) -> Result<String, Status> {
// Check if a definition exists for this table in the given profile.
let definition_exists = sqlx::query!(
r#"SELECT EXISTS (
SELECT 1 FROM table_definitions td
JOIN profiles p ON td.profile_id = p.id
WHERE p.name = $1 AND td.table_name = $2
)"#,
profile_name,
table_name
)
.fetch_one(db_pool)
.await
.map_err(|e| Status::internal(format!("Schema lookup failed: {}", e)))?
.exists
.unwrap_or(false);
if definition_exists {
// It's a user-defined table, so it lives in 'gen'.
Ok(format!("gen.\"{}\"", table_name))
} else {
// It's not a user-defined table, so it must be a system table in 'public'.
Ok(format!("\"{}\"", table_name))
}
}
/// Qualifies table names for data operations
pub async fn qualify_table_name_for_data(
db_pool: &PgPool,
profile_name: &str,
table_name: &str,
) -> Result<String, Status> {
qualify_table_name(db_pool, profile_name, table_name).await
}

View File

@@ -1,18 +1,21 @@
// src/table_definition/handlers/post_table_definition.rs
use tonic::Status;
use sqlx::{PgPool, Transaction, Postgres};
use serde_json::json;
use time::OffsetDateTime;
use common::proto::multieko2::table_definition::{PostTableDefinitionRequest, TableDefinitionResponse};
const GENERATED_SCHEMA_NAME: &str = "gen";
const PREDEFINED_FIELD_TYPES: &[(&str, &str)] = &[
("text", "TEXT"),
("psc", "TEXT"),
("phone", "VARCHAR(15)"),
("address", "TEXT"),
("email", "VARCHAR(255)"),
("string", "TEXT"),
("boolean", "BOOLEAN"),
("timestamp", "TIMESTAMPTZ"),
("time", "TIMESTAMPTZ"),
("money", "NUMERIC(14, 4)"),
("integer", "INTEGER"),
("date", "DATE"),
];
fn is_valid_identifier(s: &str) -> bool {
@@ -23,12 +26,9 @@ fn is_valid_identifier(s: &str) -> bool {
}
fn sanitize_table_name(s: &str) -> String {
let year = OffsetDateTime::now_utc().year();
let cleaned = s.replace(|c: char| !c.is_ascii_alphanumeric() && c != '_', "")
s.replace(|c: char| !c.is_ascii_alphanumeric() && c != '_', "")
.trim()
.to_lowercase();
format!("{}_{}", year, cleaned)
.to_lowercase()
}
fn sanitize_identifier(s: &str) -> String {
@@ -37,41 +37,116 @@ fn sanitize_identifier(s: &str) -> String {
.to_lowercase()
}
fn map_field_type(field_type: &str) -> Result<&str, Status> {
fn map_field_type(field_type: &str) -> Result<String, Status> {
let lower_field_type = field_type.to_lowercase();
// Special handling for "decimal(precision, scale)"
if lower_field_type.starts_with("decimal(") && lower_field_type.ends_with(')') {
// Extract the part inside the parentheses, e.g., "10, 2"
let args = lower_field_type
.strip_prefix("decimal(")
.and_then(|s| s.strip_suffix(')'))
.unwrap_or(""); // Should always succeed due to the checks above
// Split into precision and scale parts
if let Some((p_str, s_str)) = args.split_once(',') {
// Parse precision, returning an error if it's not a valid number
let precision = p_str.trim().parse::<u32>().map_err(|_| {
Status::invalid_argument("Invalid precision in decimal type")
})?;
// Parse scale, returning an error if it's not a valid number
let scale = s_str.trim().parse::<u32>().map_err(|_| {
Status::invalid_argument("Invalid scale in decimal type")
})?;
// Add validation based on PostgreSQL rules
if precision < 1 {
return Err(Status::invalid_argument("Precision must be at least 1"));
}
if scale > precision {
return Err(Status::invalid_argument(
"Scale cannot be greater than precision",
));
}
// If everything is valid, build and return the NUMERIC type string
return Ok(format!("NUMERIC({}, {})", precision, scale));
} else {
// The format was wrong, e.g., "decimal(10)" or "decimal()"
return Err(Status::invalid_argument(
"Invalid decimal format. Expected: decimal(precision, scale)",
));
}
}
// If not a decimal, fall back to the predefined list
PREDEFINED_FIELD_TYPES
.iter()
.find(|(key, _)| *key == field_type.to_lowercase().as_str())
.map(|(_, sql_type)| *sql_type)
.ok_or_else(|| Status::invalid_argument(format!("Invalid field type: {}", field_type)))
.find(|(key, _)| *key == lower_field_type.as_str())
.map(|(_, sql_type)| sql_type.to_string()) // Convert to an owned String
.ok_or_else(|| {
Status::invalid_argument(format!(
"Invalid field type: {}",
field_type
))
})
}
fn is_invalid_table_name(table_name: &str) -> bool {
table_name.ends_with("_id") ||
table_name == "id" ||
table_name == "deleted" ||
table_name == "created_at"
}
pub async fn post_table_definition(
db_pool: &PgPool,
request: PostTableDefinitionRequest, // Removed `mut` since it's not needed here
request: PostTableDefinitionRequest,
) -> Result<TableDefinitionResponse, Status> {
// Validate and sanitize table name
let table_name = sanitize_table_name(&request.table_name);
if !is_valid_identifier(&request.table_name) {
return Err(Status::invalid_argument("Invalid table name"));
if request.profile_name.trim().is_empty() {
return Err(Status::invalid_argument("Profile name cannot be empty"));
}
const MAX_IDENTIFIER_LENGTH: usize = 63;
let base_name = sanitize_table_name(&request.table_name);
if base_name.len() > MAX_IDENTIFIER_LENGTH {
return Err(Status::invalid_argument(format!(
"Identifier '{}' exceeds the {} character limit.",
base_name,
MAX_IDENTIFIER_LENGTH
)));
}
let user_part_cleaned = request.table_name
.replace(|c: char| !c.is_ascii_alphanumeric() && c != '_', "")
.trim_matches('_')
.to_lowercase();
// New validation check
if is_invalid_table_name(&user_part_cleaned) {
return Err(Status::invalid_argument(
"Table name cannot be 'id', 'deleted', 'created_at' or end with '_id'"
));
}
if !user_part_cleaned.is_empty() && !is_valid_identifier(&user_part_cleaned) {
return Err(Status::invalid_argument("Invalid table name"));
} else if user_part_cleaned.is_empty() {
return Err(Status::invalid_argument("Table name cannot be empty"));
}
// Start a transaction to ensure atomicity
let mut tx = db_pool.begin().await
.map_err(|e| Status::internal(format!("Failed to start transaction: {}", e)))?;
// Execute all database operations within the transaction
let result = execute_table_definition(&mut tx, request, table_name).await;
// Commit or rollback based on the result
match result {
match execute_table_definition(&mut tx, request, base_name).await {
Ok(response) => {
// Commit the transaction
tx.commit().await
.map_err(|e| Status::internal(format!("Failed to commit transaction: {}", e)))?;
Ok(response)
},
Err(e) => {
// Explicitly roll back the transaction (optional but good for clarity)
let _ = tx.rollback().await;
Err(e)
}
@@ -83,7 +158,6 @@ async fn execute_table_definition(
mut request: PostTableDefinitionRequest,
table_name: String,
) -> Result<TableDefinitionResponse, Status> {
// Lookup or create profile
let profile = sqlx::query!(
"INSERT INTO profiles (name) VALUES ($1)
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
@@ -94,7 +168,6 @@ async fn execute_table_definition(
.await
.map_err(|e| Status::internal(format!("Profile error: {}", e)))?;
// Process table links
let mut links = Vec::new();
for link in request.links.drain(..) {
let linked_table = sqlx::query!(
@@ -114,31 +187,33 @@ async fn execute_table_definition(
links.push((linked_id, link.required));
}
// Process columns
let mut columns = Vec::new();
for col_def in request.columns.drain(..) {
let col_name = sanitize_identifier(&col_def.name);
if !is_valid_identifier(&col_def.name) {
return Err(Status::invalid_argument("Invalid column name"));
}
if col_name.ends_with("_id") || col_name == "id" || col_name == "deleted" || col_name == "created_at" {
return Err(Status::invalid_argument("Invalid column name"));
}
let sql_type = map_field_type(&col_def.field_type)?;
columns.push(format!("\"{}\" {}", col_name, sql_type));
}
// Process indexes
let mut indexes = Vec::new();
for idx in request.indexes.drain(..) {
let idx_name = sanitize_identifier(&idx);
if !is_valid_identifier(&idx) {
return Err(Status::invalid_argument(format!("Invalid index name: {}", idx)));
}
if !columns.iter().any(|c| c.starts_with(&format!("\"{}\"", idx_name))) {
return Err(Status::invalid_argument(format!("Index column {} not found", idx_name)));
}
indexes.push(idx_name);
}
// Generate SQL with multiple links
let (create_sql, index_sql) = generate_table_sql(tx, &table_name, &columns, &indexes, &links).await?;
// Store main table definition
let table_def = sqlx::query!(
r#"INSERT INTO table_definitions
(profile_id, table_name, columns, indexes)
@@ -160,7 +235,6 @@ async fn execute_table_definition(
Status::internal(format!("Database error: {}", e))
})?;
// Store relationships
for (linked_id, is_required) in links {
sqlx::query!(
"INSERT INTO table_definition_links
@@ -175,7 +249,6 @@ async fn execute_table_definition(
.map_err(|e| Status::internal(format!("Failed to save link: {}", e)))?;
}
// Execute generated SQL within the transaction
sqlx::query(&create_sql)
.execute(&mut **tx)
.await
@@ -201,60 +274,60 @@ async fn generate_table_sql(
indexes: &[String],
links: &[(i64, bool)],
) -> Result<(String, Vec<String>), Status> {
let qualified_table = format!("{}.\"{}\"", GENERATED_SCHEMA_NAME, table_name);
let mut system_columns = vec![
"id BIGSERIAL PRIMARY KEY".to_string(),
"deleted BOOLEAN NOT NULL DEFAULT FALSE".to_string(),
];
// Add foreign key columns
let mut link_info = Vec::new();
for (linked_id, required) in links {
let linked_table = get_table_name_by_id(tx, *linked_id).await?;
// Extract base name after year prefix
let qualified_linked_table = format!("{}.\"{}\"", GENERATED_SCHEMA_NAME, linked_table);
let base_name = linked_table.split_once('_')
.map(|(_, rest)| rest)
.unwrap_or(&linked_table)
.to_string();
let null_clause = if *required { "NOT NULL" } else { "" };
system_columns.push(
format!("\"{0}_id\" BIGINT {1} REFERENCES \"{2}\"(id)",
base_name, null_clause, linked_table
format!("\"{0}_id\" BIGINT {1} REFERENCES {2}(id)",
base_name, null_clause, qualified_linked_table
)
);
link_info.push((base_name, linked_table));
}
// Combine all columns
let all_columns = system_columns
.iter()
.chain(columns.iter())
.cloned()
.collect::<Vec<_>>();
// Build CREATE TABLE statement
let create_sql = format!(
"CREATE TABLE \"{}\" (\n {},\n created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP\n)",
table_name,
"CREATE TABLE {} (\n {},\n created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP\n)",
qualified_table,
all_columns.join(",\n ")
);
// Generate indexes
let mut system_indexes = Vec::new();
for (base_name, _) in &link_info {
system_indexes.push(format!(
"CREATE INDEX idx_{}_{}_fk ON \"{}\" (\"{}_id\")",
table_name, base_name, table_name, base_name
let mut all_indexes = Vec::new();
for (linked_id, _) in links {
let linked_table = get_table_name_by_id(tx, *linked_id).await?;
let base_name = linked_table.split_once('_')
.map(|(_, rest)| rest)
.unwrap_or(&linked_table)
.to_string();
all_indexes.push(format!(
"CREATE INDEX \"idx_{}_{}_fk\" ON {} (\"{}_id\")",
table_name, base_name, qualified_table, base_name
));
}
let all_indexes = system_indexes
.into_iter()
.chain(indexes.iter().map(|idx| {
format!("CREATE INDEX idx_{}_{} ON \"{}\" (\"{}\")",
table_name, idx, table_name, idx)
}))
.collect();
for idx in indexes {
all_indexes.push(format!(
"CREATE INDEX \"idx_{}_{}\" ON {} (\"{}\")",
table_name, idx, qualified_table, idx
));
}
Ok((create_sql, all_indexes))
}

View File

@@ -2,7 +2,7 @@
use common::proto::multieko2::table_structure::{
GetTableStructureRequest, TableColumn, TableStructureResponse,
};
use sqlx::{PgPool, Row};
use sqlx::PgPool;
use tonic::Status;
// Helper struct to map query results
@@ -19,8 +19,8 @@ pub async fn get_table_structure(
request: GetTableStructureRequest,
) -> Result<TableStructureResponse, Status> {
let profile_name = request.profile_name;
let table_name = request.table_name; // This should be the full table name, e.g., "2025_adresar6"
let table_schema = "public"; // Assuming tables are in the 'public' schema
let table_name = request.table_name;
let table_schema = "gen";
// 1. Validate Profile
let profile = sqlx::query!(

View File

@@ -2,6 +2,7 @@
use tonic::Status;
use sqlx::PgPool;
use common::proto::multieko2::tables_data::{DeleteTableDataRequest, DeleteTableDataResponse};
use crate::shared::schema_qualifier::qualify_table_name_for_data; // Import schema qualifier
pub async fn delete_table_data(
db_pool: &PgPool,
@@ -36,20 +37,42 @@ pub async fn delete_table_data(
return Err(Status::not_found("Table not found in profile"));
}
// Perform soft delete
// Qualify table name with schema
let qualified_table = qualify_table_name_for_data(
db_pool,
&request.profile_name,
&request.table_name,
)
.await?;
// Perform soft delete using qualified table name
let query = format!(
"UPDATE \"{}\"
"UPDATE {}
SET deleted = true
WHERE id = $1 AND deleted = false",
request.table_name
qualified_table
);
let rows_affected = sqlx::query(&query)
let result = sqlx::query(&query)
.bind(request.record_id)
.execute(db_pool)
.await
.map_err(|e| Status::internal(format!("Delete operation failed: {}", e)))?
.rows_affected();
.await;
let rows_affected = match result {
Ok(result) => result.rows_affected(),
Err(e) => {
// Handle "relation does not exist" error specifically
if let Some(db_err) = e.as_database_error() {
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
return Err(Status::internal(format!(
"Table '{}' is defined but does not physically exist in the database as {}",
request.table_name, qualified_table
)));
}
}
return Err(Status::internal(format!("Delete operation failed: {}", e)));
}
};
Ok(DeleteTableDataResponse {
success: rows_affected > 0,

View File

@@ -1,8 +1,10 @@
// src/tables_data/handlers/get_table_data.rs
use tonic::Status;
use sqlx::{PgPool, Row};
use std::collections::HashMap;
use common::proto::multieko2::tables_data::{GetTableDataRequest, GetTableDataResponse};
use crate::shared::schema_qualifier::qualify_table_name_for_data;
pub async fn get_table_data(
db_pool: &PgPool,
@@ -47,46 +49,82 @@ pub async fn get_table_data(
return Err(Status::internal("Invalid column format"));
}
let name = parts[0].trim_matches('"').to_string();
let sql_type = parts[1].to_string();
user_columns.push((name, sql_type));
user_columns.push(name);
}
// Prepare all columns (system + user-defined)
let system_columns = vec![
("id".to_string(), "BIGINT".to_string()),
("deleted".to_string(), "BOOLEAN".to_string()),
("firma".to_string(), "TEXT".to_string()),
];
let all_columns: Vec<(String, String)> = system_columns
.into_iter()
.chain(user_columns.into_iter())
.collect();
// --- START OF FIX ---
// Build SELECT clause with COALESCE and type casting
let columns_clause = all_columns
// 1. Get all foreign key columns for this table
let fk_columns_query = sqlx::query!(
r#"SELECT ltd.table_name
FROM table_definition_links tdl
JOIN table_definitions ltd ON tdl.linked_table_id = ltd.id
WHERE tdl.source_table_id = $1"#,
table_def.id
)
.fetch_all(db_pool)
.await
.map_err(|e| Status::internal(format!("Foreign key lookup error: {}", e)))?;
// 2. Build the list of foreign key column names
let mut foreign_key_columns = Vec::new();
for fk in fk_columns_query {
let base_name = fk.table_name.split_once('_').map_or(fk.table_name.as_str(), |(_, rest)| rest);
foreign_key_columns.push(format!("{}_id", base_name));
}
// 3. Prepare a complete list of all columns to select
let mut all_column_names = vec!["id".to_string(), "deleted".to_string()];
all_column_names.extend(user_columns);
all_column_names.extend(foreign_key_columns);
// 4. Build the SELECT clause with all columns
let columns_clause = all_column_names
.iter()
.map(|(name, _)| format!("COALESCE(\"{0}\"::TEXT, '') AS \"{0}\"", name))
.map(|name| format!("COALESCE(\"{0}\"::TEXT, '') AS \"{0}\"", name))
.collect::<Vec<_>>()
.join(", ");
// --- END OF FIX ---
// Qualify table name with schema
let qualified_table = qualify_table_name_for_data(
db_pool,
&profile_name,
&table_name,
)
.await?;
let sql = format!(
"SELECT {} FROM \"{}\" WHERE id = $1 AND deleted = false",
columns_clause, table_name
"SELECT {} FROM {} WHERE id = $1 AND deleted = false",
columns_clause, qualified_table
);
// Execute query
let row = sqlx::query(&sql)
// Execute query with enhanced error handling
let row_result = sqlx::query(&sql)
.bind(record_id)
.fetch_one(db_pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => Status::not_found("Record not found"),
_ => Status::internal(format!("Database error: {}", e)),
})?;
.await;
// Build response data
let row = match row_result {
Ok(row) => row,
Err(sqlx::Error::RowNotFound) => return Err(Status::not_found("Record not found")),
Err(e) => {
if let Some(db_err) = e.as_database_error() {
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
return Err(Status::internal(format!(
"Table '{}' is defined but does not physically exist in the database as {}",
table_name, qualified_table
)));
}
}
return Err(Status::internal(format!("Database error: {}", e)));
}
};
// Build response data from the complete list of columns
let mut data = HashMap::new();
for (column_name, _) in &all_columns {
for column_name in &all_column_names {
let value: String = row
.try_get(column_name.as_str())
.map_err(|e| Status::internal(format!("Failed to get column {}: {}", column_name, e)))?;

View File

@@ -5,6 +5,7 @@ use common::proto::multieko2::tables_data::{
GetTableDataByPositionRequest, GetTableDataRequest, GetTableDataResponse
};
use super::get_table_data;
use crate::shared::schema_qualifier::qualify_table_name_for_data; // Import schema qualifier
pub async fn get_table_data_by_position(
db_pool: &PgPool,
@@ -27,39 +28,60 @@ pub async fn get_table_data_by_position(
let profile_id = profile.ok_or_else(|| Status::not_found("Profile not found"))?.id;
let table_exists = sqlx::query!(
let table_exists = sqlx::query_scalar!(
r#"SELECT EXISTS(
SELECT 1 FROM table_definitions
WHERE profile_id = $1 AND table_name = $2
)"#,
) AS "exists!""#,
profile_id,
table_name
)
.fetch_one(db_pool)
.await
.map_err(|e| Status::internal(format!("Table verification error: {}", e)))?
.exists
.unwrap_or(false);
.map_err(|e| Status::internal(format!("Table verification error: {}", e)))?;
if !table_exists {
return Err(Status::not_found("Table not found"));
}
let id: i64 = sqlx::query_scalar(
// Qualify table name with schema
let qualified_table = qualify_table_name_for_data(
db_pool,
&profile_name,
&table_name,
)
.await?;
let id_result = sqlx::query_scalar(
&format!(
r#"SELECT id FROM "{}"
WHERE deleted = FALSE
ORDER BY id ASC
OFFSET $1
r#"SELECT id FROM {}
WHERE deleted = FALSE
ORDER BY id ASC
OFFSET $1
LIMIT 1"#,
table_name
qualified_table
)
)
.bind(request.position - 1)
.fetch_optional(db_pool)
.await
.map_err(|e| Status::internal(format!("Position query failed: {}", e)))?
.ok_or_else(|| Status::not_found("Position out of bounds"))?;
.await;
let id: i64 = match id_result {
Ok(Some(id)) => id,
Ok(None) => return Err(Status::not_found("Position out of bounds")),
Err(e) => {
// Handle "relation does not exist" error specifically
if let Some(db_err) = e.as_database_error() {
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
return Err(Status::internal(format!(
"Table '{}' is defined but does not physically exist in the database as {}",
table_name, qualified_table
)));
}
}
return Err(Status::internal(format!("Position query failed: {}", e)));
}
};
get_table_data(
db_pool,

View File

@@ -3,59 +3,98 @@ use tonic::Status;
use sqlx::PgPool;
use common::proto::multieko2::common::CountResponse;
use common::proto::multieko2::tables_data::GetTableDataCountRequest;
use crate::shared::schema_qualifier::qualify_table_name_for_data; // 1. IMPORT THE FUNCTION
pub async fn get_table_data_count(
db_pool: &PgPool,
request: GetTableDataCountRequest,
) -> Result<CountResponse, Status> {
let profile_name = request.profile_name;
let table_name = request.table_name;
// Lookup profile
// We still need to verify that the table is logically defined for the profile.
// The schema qualifier handles *how* to access it physically, but this check
// ensures the request is valid in the context of the application's definitions.
let profile = sqlx::query!(
"SELECT id FROM profiles WHERE name = $1",
profile_name
request.profile_name
)
.fetch_optional(db_pool)
.await
.map_err(|e| Status::internal(format!("Profile lookup error: {}", e)))?;
.map_err(|e| Status::internal(format!("Profile lookup error for '{}': {}", request.profile_name, e)))?;
let profile_id = profile.ok_or_else(|| Status::not_found("Profile not found"))?.id;
let profile_id = match profile {
Some(p) => p.id,
None => return Err(Status::not_found(format!("Profile '{}' not found", request.profile_name))),
};
// Verify table exists and belongs to profile
let table_exists = sqlx::query!(
let table_defined_for_profile = sqlx::query_scalar!(
r#"SELECT EXISTS(
SELECT 1 FROM table_definitions
WHERE profile_id = $1 AND table_name = $2
)"#,
) AS "exists!" "#, // Added AS "exists!" for clarity with sqlx macro
profile_id,
table_name
request.table_name
)
.fetch_one(db_pool)
.await
.map_err(|e| Status::internal(format!("Table verification error: {}", e)))?
.exists
.unwrap_or(false);
.map_err(|e| Status::internal(format!("Table definition verification error for '{}.{}': {}", request.profile_name, request.table_name, e)))?;
if !table_exists {
return Err(Status::not_found("Table not found"));
if !table_defined_for_profile {
// If the table isn't even defined for this profile in table_definitions,
// it's an error, regardless of whether a physical table with that name exists somewhere.
return Err(Status::not_found(format!(
"Table '{}' is not defined for profile '{}'",
request.table_name, request.profile_name
)));
}
// Get count of non-deleted records
let query = format!(
// 2. QUALIFY THE TABLE NAME using the imported function
let qualified_table = qualify_table_name_for_data(
db_pool,
&request.profile_name,
&request.table_name,
)
.await?;
// 3. USE THE QUALIFIED NAME in the SQL query
let query_sql = format!(
r#"
SELECT COUNT(*) AS count
FROM "{}"
FROM {}
WHERE deleted = FALSE
"#,
table_name
qualified_table
);
let count: i64 = sqlx::query_scalar::<_, Option<i64>>(&query)
// The rest of the logic remains largely the same, but error messages can be more specific.
let count_result = sqlx::query_scalar::<_, Option<i64>>(&query_sql)
.fetch_one(db_pool)
.await
.map_err(|e| Status::internal(format!("Count query failed: {}", e)))?
.unwrap_or(0);
.await;
Ok(CountResponse { count })
match count_result {
Ok(Some(count_val)) => Ok(CountResponse { count: count_val }),
Ok(None) => {
// This case should ideally not be reached with COUNT(*),
// as it always returns a row, even if the count is 0.
// If it does, it might indicate an issue or an empty table if the query was different.
// For COUNT(*), a 0 count is expected if no non-deleted rows.
Ok(CountResponse { count: 0 })
}
Err(e) => {
// Check if the error is "relation does not exist" (PostgreSQL error code 42P01)
if let Some(db_err) = e.as_database_error() {
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
// This means the table (e.g., gen."2025_test_schema3") does not physically exist,
// even though it was defined in table_definitions. This is an inconsistency.
return Err(Status::internal(format!(
"Table '{}' is defined but does not physically exist in the database as {}.",
request.table_name, qualified_table
)));
}
}
// For other errors, provide a general message.
Err(Status::internal(format!(
"Count query failed for table {}: {}",
qualified_table, e
)))
}
}
}

View File

@@ -1,4 +1,5 @@
// src/tables_data/handlers/post_table_data.rs
use tonic::Status;
use sqlx::{PgPool, Arguments};
use sqlx::postgres::PgArguments;
@@ -6,32 +7,22 @@ use chrono::{DateTime, Utc};
use common::proto::multieko2::tables_data::{PostTableDataRequest, PostTableDataResponse};
use std::collections::HashMap;
use std::sync::Arc;
use prost_types::value::Kind;
use crate::steel::server::execution::{self, Value};
use crate::steel::server::functions::SteelContext;
use crate::indexer::{IndexCommand, IndexCommandData};
use tokio::sync::mpsc;
use tracing::error;
pub async fn post_table_data(
db_pool: &PgPool,
request: PostTableDataRequest,
indexer_tx: &mpsc::Sender<IndexCommand>,
) -> Result<PostTableDataResponse, Status> {
let profile_name = request.profile_name;
let table_name = request.table_name;
let mut data = HashMap::new();
// Process and validate all data values
for (key, value) in request.data {
let trimmed = value.trim().to_string();
// Handle specially - it cannot be empty
if trimmed.is_empty() {
return Err(Status::invalid_argument("Firma cannot be empty"));
}
// Add trimmed non-empty values to data map
if !trimmed.is_empty() {
data.insert(key, trimmed);
}
}
// Lookup profile
let profile = sqlx::query!(
@@ -87,7 +78,7 @@ pub async fn post_table_data(
// Build system columns with foreign keys
let mut system_columns = vec!["deleted".to_string()];
for fk in fk_columns {
let base_name = fk.table_name.split('_').last().unwrap_or(&fk.table_name);
let base_name = fk.table_name.split_once('_').map_or(fk.table_name.as_str(), |(_, rest)| rest);
system_columns.push(format!("{}_id", base_name));
}
@@ -96,13 +87,32 @@ pub async fn post_table_data(
// Validate all data columns
let user_columns: Vec<&String> = columns.iter().map(|(name, _)| name).collect();
for key in data.keys() {
if !system_columns_set.contains(key.as_str()) &&
for key in request.data.keys() {
if !system_columns_set.contains(key.as_str()) &&
!user_columns.contains(&&key.to_string()) {
return Err(Status::invalid_argument(format!("Invalid column: {}", key)));
}
}
// ========================================================================
// FIX #1: SCRIPT VALIDATION LOOP
// This loop now correctly handles JSON `null` (which becomes `None`).
// ========================================================================
let mut string_data_for_scripts = HashMap::new();
for (key, proto_value) in &request.data {
let str_val = match &proto_value.kind {
Some(Kind::StringValue(s)) => s.clone(),
Some(Kind::NumberValue(n)) => n.to_string(),
Some(Kind::BoolValue(b)) => b.to_string(),
// This now correctly skips both protobuf `NULL` and JSON `null`.
Some(Kind::NullValue(_)) | None => continue,
Some(Kind::StructValue(_)) | Some(Kind::ListValue(_)) => {
return Err(Status::invalid_argument(format!("Unsupported type for script validation in column '{}'", key)));
}
};
string_data_for_scripts.insert(key.clone(), str_val);
}
// Validate Steel scripts
let scripts = sqlx::query!(
"SELECT target_column, script FROM table_scripts WHERE table_definitions_id = $1",
@@ -115,22 +125,18 @@ pub async fn post_table_data(
for script_record in scripts {
let target_column = script_record.target_column;
// Ensure target column exists in submitted data
let user_value = data.get(&target_column)
let user_value = string_data_for_scripts.get(&target_column)
.ok_or_else(|| Status::invalid_argument(
format!("Script target column '{}' is required", target_column)
))?;
// Create execution context
let context = SteelContext {
current_table: table_name.clone(),
profile_id,
row_data: data.clone(),
row_data: string_data_for_scripts.clone(),
db_pool: Arc::new(db_pool.clone()),
};
// Execute validation script
let script_result = execution::execute_script(
script_record.script,
"STRINGS",
@@ -141,7 +147,6 @@ pub async fn post_table_data(
format!("Script execution failed for '{}': {}", target_column, e)
))?;
// Validate script output
let Value::Strings(mut script_output) = script_result else {
return Err(Status::internal("Script must return string values"));
};
@@ -163,11 +168,16 @@ pub async fn post_table_data(
let mut placeholders = Vec::new();
let mut param_idx = 1;
for (col, value) in data {
// ========================================================================
// FIX #2: DATABASE INSERTION LOOP
// This loop now correctly handles JSON `null` (which becomes `None`)
// without crashing and correctly inserts a SQL NULL.
// ========================================================================
for (col, proto_value) in request.data {
let sql_type = if system_columns_set.contains(col.as_str()) {
match col.as_str() {
"deleted" => "BOOLEAN",
_ if col.ends_with("_id") => "BIGINT", // Handle foreign keys
_ if col.ends_with("_id") => "BIGINT",
_ => return Err(Status::invalid_argument("Invalid system column")),
}
} else {
@@ -177,36 +187,65 @@ pub async fn post_table_data(
.ok_or_else(|| Status::invalid_argument(format!("Column not found: {}", col)))?
};
// Check for `None` (from JSON null) or `Some(NullValue)` first.
let kind = match &proto_value.kind {
None | Some(Kind::NullValue(_)) => {
// It's a null value. Add the correct SQL NULL type and continue.
match sql_type {
"BOOLEAN" => params.add(None::<bool>),
"TEXT" | "VARCHAR(15)" | "VARCHAR(255)" => params.add(None::<String>),
"TIMESTAMPTZ" => params.add(None::<DateTime<Utc>>),
"BIGINT" => params.add(None::<i64>),
_ => return Err(Status::invalid_argument(format!("Unsupported type for null value: {}", sql_type))),
}.map_err(|e| Status::internal(format!("Failed to add null parameter for {}: {}", col, e)))?;
columns_list.push(format!("\"{}\"", col));
placeholders.push(format!("${}", param_idx));
param_idx += 1;
continue; // Skip to the next column in the loop
}
// If it's not null, just pass the inner `Kind` through.
Some(k) => k,
};
// From here, we know `kind` is not a null type.
match sql_type {
"TEXT" | "VARCHAR(15)" | "VARCHAR(255)" => {
if let Some(max_len) = sql_type.strip_prefix("VARCHAR(")
.and_then(|s| s.strip_suffix(')'))
.and_then(|s| s.parse::<usize>().ok())
{
if value.len() > max_len {
return Err(Status::internal(format!("Value too long for {}", col)));
if let Kind::StringValue(value) = kind {
if let Some(max_len) = sql_type.strip_prefix("VARCHAR(").and_then(|s| s.strip_suffix(')')).and_then(|s| s.parse::<usize>().ok()) {
if value.len() > max_len {
return Err(Status::internal(format!("Value too long for {}", col)));
}
}
params.add(value).map_err(|e| Status::invalid_argument(format!("Failed to add text parameter for {}: {}", col, e)))?;
} else {
return Err(Status::invalid_argument(format!("Expected string for column '{}'", col)));
}
params.add(value)
.map_err(|e| Status::invalid_argument(format!("Failed to add text parameter for {}: {}", col, e)))?;
},
"BOOLEAN" => {
let val = value.parse::<bool>()
.map_err(|_| Status::invalid_argument(format!("Invalid boolean for {}", col)))?;
params.add(val)
.map_err(|e| Status::invalid_argument(format!("Failed to add boolean parameter for {}: {}", col, e)))?;
if let Kind::BoolValue(val) = kind {
params.add(val).map_err(|e| Status::invalid_argument(format!("Failed to add boolean parameter for {}: {}", col, e)))?;
} else {
return Err(Status::invalid_argument(format!("Expected boolean for column '{}'", col)));
}
},
"TIMESTAMPTZ" => {
let dt = DateTime::parse_from_rfc3339(&value)
.map_err(|_| Status::invalid_argument(format!("Invalid timestamp for {}", col)))?;
params.add(dt.with_timezone(&Utc))
.map_err(|e| Status::invalid_argument(format!("Failed to add timestamp parameter for {}: {}", col, e)))?;
if let Kind::StringValue(value) = kind {
let dt = DateTime::parse_from_rfc3339(value).map_err(|_| Status::invalid_argument(format!("Invalid timestamp for {}", col)))?;
params.add(dt.with_timezone(&Utc)).map_err(|e| Status::invalid_argument(format!("Failed to add timestamp parameter for {}: {}", col, e)))?;
} else {
return Err(Status::invalid_argument(format!("Expected ISO 8601 string for column '{}'", col)));
}
},
"BIGINT" => {
let val = value.parse::<i64>()
.map_err(|_| Status::invalid_argument(format!("Invalid integer for {}", col)))?;
params.add(val)
.map_err(|e| Status::invalid_argument(format!("Failed to add integer parameter for {}: {}", col, e)))?;
if let Kind::NumberValue(val) = kind {
if val.fract() != 0.0 {
return Err(Status::invalid_argument(format!("Expected integer for column '{}', but got a float", col)));
}
params.add(*val as i64).map_err(|e| Status::invalid_argument(format!("Failed to add integer parameter for {}: {}", col, e)))?;
} else {
return Err(Status::invalid_argument(format!("Expected number for column '{}'", col)));
}
},
_ => return Err(Status::invalid_argument(format!("Unsupported type {}", sql_type))),
}
@@ -220,17 +259,51 @@ pub async fn post_table_data(
return Err(Status::invalid_argument("No valid columns to insert"));
}
// Qualify table name with schema
let qualified_table = crate::shared::schema_qualifier::qualify_table_name_for_data(
db_pool,
&profile_name,
&table_name,
)
.await?;
let sql = format!(
"INSERT INTO \"{}\" ({}) VALUES ({}) RETURNING id",
table_name,
"INSERT INTO {} ({}) VALUES ({}) RETURNING id",
qualified_table,
columns_list.join(", "),
placeholders.join(", ")
);
let inserted_id: i64 = sqlx::query_scalar_with(&sql, params)
let result = sqlx::query_scalar_with::<_, i64, _>(&sql, params)
.fetch_one(db_pool)
.await
.map_err(|e| Status::internal(format!("Insert failed: {}", e)))?;
.await;
let inserted_id = match result {
Ok(id) => id,
Err(e) => {
if let Some(db_err) = e.as_database_error() {
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
return Err(Status::internal(format!(
"Table '{}' is defined but does not physically exist in the database as {}",
table_name, qualified_table
)));
}
}
return Err(Status::internal(format!("Insert failed: {}", e)));
}
};
let command = IndexCommand::AddOrUpdate(IndexCommandData {
table_name: table_name.clone(),
row_id: inserted_id,
});
if let Err(e) = indexer_tx.send(command).await {
error!(
"CRITICAL: DB insert for table '{}' (id: {}) succeeded but failed to queue for indexing: {}. Search index is now inconsistent.",
table_name, inserted_id, e
);
}
Ok(PostTableDataResponse {
success: true,

View File

@@ -4,7 +4,8 @@ use sqlx::{PgPool, Arguments, Postgres};
use sqlx::postgres::PgArguments;
use chrono::{DateTime, Utc};
use common::proto::multieko2::tables_data::{PutTableDataRequest, PutTableDataResponse};
use std::collections::HashMap;
use crate::shared::schema_qualifier::qualify_table_name_for_data;
use prost_types::value::Kind;
pub async fn put_table_data(
db_pool: &PgPool,
@@ -13,24 +14,10 @@ pub async fn put_table_data(
let profile_name = request.profile_name;
let table_name = request.table_name;
let record_id = request.id;
// Preprocess and validate data
let mut processed_data = HashMap::new();
let mut null_fields = Vec::new();
for (key, value) in request.data {
let trimmed = value.trim().to_string();
if key == "firma" && trimmed.is_empty() {
return Err(Status::invalid_argument("Firma cannot be empty"));
}
// Store fields that should be set to NULL
if key != "firma" && trimmed.is_empty() {
null_fields.push(key);
} else {
processed_data.insert(key, trimmed);
}
// If no data is provided to update, it's an invalid request.
if request.data.is_empty() {
return Err(Status::invalid_argument("No fields provided to update."));
}
// Lookup profile
@@ -72,13 +59,29 @@ pub async fn put_table_data(
columns.push((name, sql_type));
}
// Validate system columns
let system_columns = ["firma", "deleted"];
// Get all foreign key columns for this table (needed for validation)
let fk_columns = sqlx::query!(
r#"SELECT ltd.table_name
FROM table_definition_links tdl
JOIN table_definitions ltd ON tdl.linked_table_id = ltd.id
WHERE tdl.source_table_id = $1"#,
table_def.id
)
.fetch_all(db_pool)
.await
.map_err(|e| Status::internal(format!("Foreign key lookup error: {}", e)))?;
let mut system_columns = vec!["deleted".to_string()];
for fk in fk_columns {
let base_name = fk.table_name.split_once('_').map_or(fk.table_name.as_str(), |(_, rest)| rest);
system_columns.push(format!("{}_id", base_name));
}
let system_columns_set: std::collections::HashSet<_> = system_columns.iter().map(|s| s.as_str()).collect();
let user_columns: Vec<&String> = columns.iter().map(|(name, _)| name).collect();
// Validate input columns
for key in processed_data.keys() {
if !system_columns.contains(&key.as_str()) && !user_columns.contains(&key) {
for key in request.data.keys() {
if !system_columns_set.contains(key.as_str()) && !user_columns.contains(&key) {
return Err(Status::invalid_argument(format!("Invalid column: {}", key)));
}
}
@@ -88,46 +91,65 @@ pub async fn put_table_data(
let mut set_clauses = Vec::new();
let mut param_idx = 1;
// Add data parameters for non-empty fields
for (col, value) in &processed_data {
let sql_type = if system_columns.contains(&col.as_str()) {
for (col, proto_value) in request.data {
let sql_type = if system_columns_set.contains(col.as_str()) {
match col.as_str() {
"firma" => "TEXT",
"deleted" => "BOOLEAN",
_ if col.ends_with("_id") => "BIGINT",
_ => return Err(Status::invalid_argument("Invalid system column")),
}
} else {
columns.iter()
.find(|(name, _)| name == col)
.find(|(name, _)| name == &col)
.map(|(_, sql_type)| sql_type.as_str())
.ok_or_else(|| Status::invalid_argument(format!("Column not found: {}", col)))?
};
// TODO strong testing by user pick in the future
// A provided value cannot be null or empty in a PUT request.
// To clear a field, it should be set to an empty string "" for text,
// or a specific value for other types if needed (though typically not done).
// For now, we reject nulls.
let kind = proto_value.kind.ok_or_else(|| {
Status::invalid_argument(format!("Value for column '{}' cannot be empty in a PUT request. To clear a text field, send an empty string.", col))
})?;
match sql_type {
"TEXT" | "VARCHAR(15)" | "VARCHAR(255)" => {
if let Some(max_len) = sql_type.strip_prefix("VARCHAR(")
.and_then(|s| s.strip_suffix(')'))
.and_then(|s| s.parse::<usize>().ok())
{
if value.len() > max_len {
return Err(Status::internal(format!("Value too long for {}", col)));
}
if let Kind::StringValue(value) = kind {
params.add(value)
.map_err(|e| Status::internal(format!("Failed to add text parameter for {}: {}", col, e)))?;
} else {
return Err(Status::invalid_argument(format!("Expected string for column '{}'", col)));
}
params.add(value)
.map_err(|e| Status::internal(format!("Failed to add text parameter for {}: {}", col, e)))?;
},
"BOOLEAN" => {
let val = value.parse::<bool>()
.map_err(|_| Status::invalid_argument(format!("Invalid boolean for {}", col)))?;
params.add(val)
.map_err(|e| Status::internal(format!("Failed to add boolean parameter for {}: {}", col, e)))?;
if let Kind::BoolValue(val) = kind {
params.add(val)
.map_err(|e| Status::internal(format!("Failed to add boolean parameter for {}: {}", col, e)))?;
} else {
return Err(Status::invalid_argument(format!("Expected boolean for column '{}'", col)));
}
},
"TIMESTAMPTZ" => {
let dt = DateTime::parse_from_rfc3339(value)
.map_err(|_| Status::invalid_argument(format!("Invalid timestamp for {}", col)))?;
params.add(dt.with_timezone(&Utc))
.map_err(|e| Status::internal(format!("Failed to add timestamp parameter for {}: {}", col, e)))?;
if let Kind::StringValue(value) = kind {
let dt = DateTime::parse_from_rfc3339(&value)
.map_err(|_| Status::invalid_argument(format!("Invalid timestamp for {}", col)))?;
params.add(dt.with_timezone(&Utc))
.map_err(|e| Status::internal(format!("Failed to add timestamp parameter for {}: {}", col, e)))?;
} else {
return Err(Status::invalid_argument(format!("Expected ISO 8601 string for column '{}'", col)));
}
},
"BIGINT" => {
if let Kind::NumberValue(val) = kind {
if val.fract() != 0.0 {
return Err(Status::invalid_argument(format!("Expected integer for column '{}', but got a float", col)));
}
params.add(val as i64)
.map_err(|e| Status::internal(format!("Failed to add integer parameter for {}: {}", col, e)))?;
} else {
return Err(Status::invalid_argument(format!("Expected number for column '{}'", col)));
}
},
_ => return Err(Status::invalid_argument(format!("Unsupported type {}", sql_type))),
}
@@ -136,43 +158,44 @@ pub async fn put_table_data(
param_idx += 1;
}
// Add NULL clauses for empty fields
for field in null_fields {
// Make sure the field is valid
if !system_columns.contains(&field.as_str()) && !user_columns.contains(&&field) {
return Err(Status::invalid_argument(format!("Invalid column to set NULL: {}", field)));
}
set_clauses.push(format!("\"{}\" = NULL", field));
}
// Ensure we have at least one field to update
if set_clauses.is_empty() {
return Err(Status::invalid_argument("No valid fields to update"));
}
// Add ID parameter at the end
params.add(record_id)
.map_err(|e| Status::internal(format!("Failed to add record_id parameter: {}", e)))?;
let qualified_table = qualify_table_name_for_data(
db_pool,
&profile_name,
&table_name,
)
.await?;
let set_clause = set_clauses.join(", ");
let sql = format!(
"UPDATE \"{}\" SET {} WHERE id = ${} AND deleted = FALSE RETURNING id",
table_name,
"UPDATE {} SET {} WHERE id = ${} AND deleted = FALSE RETURNING id",
qualified_table,
set_clause,
param_idx
);
let result = sqlx::query_scalar_with::<Postgres, i64, _>(&sql, params)
.fetch_optional(db_pool)
.await
.map_err(|e| Status::internal(format!("Update failed: {}", e)))?;
.await;
match result {
Some(updated_id) => Ok(PutTableDataResponse {
Ok(Some(updated_id)) => Ok(PutTableDataResponse {
success: true,
message: "Data updated successfully".into(),
updated_id,
}),
None => Err(Status::not_found("Record not found or already deleted")),
Ok(None) => Err(Status::not_found("Record not found or already deleted")),
Err(e) => {
if let Some(db_err) = e.as_database_error() {
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
return Err(Status::internal(format!(
"Table '{}' is defined but does not physically exist in the database as {}",
table_name, qualified_table
)));
}
}
Err(Status::internal(format!("Update failed: {}", e)))
}
}
}

View File

@@ -1,56 +1,75 @@
// tests/common/mod.rs
use dotenvy;
use sqlx::{postgres::PgPoolOptions, PgPool};
use dotenvy::dotenv;
// --- CHANGE 1: Add Alphanumeric to the use statement ---
use rand::distr::Alphanumeric;
use rand::Rng;
use sqlx::{postgres::PgPoolOptions, Connection, Executor, PgConnection, PgPool};
use std::env;
use std::path::Path;
pub async fn setup_test_db() -> PgPool {
// Get path to server directory
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set");
let env_path = Path::new(&manifest_dir).join(".env_test");
// (The get_database_url and get_root_connection functions remain the same)
fn get_database_url() -> String {
dotenv().ok();
env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL must be set")
}
async fn get_root_connection() -> PgConnection {
PgConnection::connect(&get_database_url())
.await
.expect("Failed to create root connection to test database")
}
// Load environment variables
dotenvy::from_path(env_path).ok();
// Create connection pool
let database_url = env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL must be set");
/// The primary test setup function.
/// Creates a new, unique schema and returns a connection pool that is scoped to that schema.
/// This is the key to test isolation.
pub async fn setup_isolated_db() -> PgPool {
let mut root_conn = get_root_connection().await;
let schema_name = format!(
"test_{}",
rand::thread_rng()
// --- CHANGE 2: Pass a reference to Alphanumeric directly ---
.sample_iter(&Alphanumeric)
.take(12)
.map(char::from)
.collect::<String>()
.to_lowercase()
);
root_conn
.execute(format!("CREATE SCHEMA \"{}\"", schema_name).as_str())
.await
.unwrap_or_else(|_| panic!("Failed to create schema: {}", schema_name));
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.after_connect(move |conn, _meta| {
let schema_name = schema_name.clone();
Box::pin(async move {
conn.execute(format!("SET search_path TO \"{}\"", schema_name).as_str())
.await?;
Ok(())
})
})
.connect(&get_database_url())
.await
.expect("Failed to create pool");
.expect("Failed to create isolated pool");
// Run migrations
sqlx::migrate!()
.run(&pool)
.await
.expect("Migrations failed");
.expect("Migrations failed in isolated schema");
// Insert default profile if it doesn't exist
let profile = sqlx::query!(
sqlx::query!(
r#"
INSERT INTO profiles (name)
VALUES ('default')
ON CONFLICT (name) DO NOTHING
RETURNING id
"#
)
.fetch_optional(&pool)
.execute(&pool)
.await
.expect("Failed to insert test profile");
let profile_id = if let Some(profile) = profile {
profile.id
} else {
// If the profile already exists, fetch its ID
sqlx::query!(
"SELECT id FROM profiles WHERE name = 'default'"
)
.fetch_one(&pool)
.await
.expect("Failed to fetch default profile ID")
.id
};
.expect("Failed to insert test profile in isolated schema");
pool
}

View File

@@ -1,5 +1,5 @@
// tests/mod.rs
pub mod adresar;
pub mod tables_data;
// pub mod adresar;
// pub mod tables_data;
pub mod common;
pub mod table_definition;

View File

@@ -0,0 +1,3 @@
// server/tests/table_definition/mod.rs
pub mod post_table_definition_test;

View File

@@ -0,0 +1,511 @@
// tests/table_definition/post_table_definition_test.rs
// Keep all your normal use statements
use common::proto::multieko2::table_definition::{
ColumnDefinition, PostTableDefinitionRequest, TableLink,
};
use rstest::{fixture, rstest};
use server::table_definition::handlers::post_table_definition;
use sqlx::{postgres::PgPoolOptions, Connection, Executor, PgConnection, PgPool, Row}; // Add PgConnection etc.
use tonic::Code;
// Add these two new use statements for the isolation logic
use rand::distr::Alphanumeric;
use rand::Rng;
use std::env;
use dotenvy;
use std::path::Path;
// ===================================================================
// SPECIALIZED SETUP FOR `table_definition` TESTS
// This setup logic is now local to this file and will not affect other tests.
// ===================================================================
async fn setup_isolated_gen_schema_db() -> PgPool {
// ---- ADD THIS BLOCK TO LOAD THE .env_test FILE ----
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set");
let env_path = Path::new(&manifest_dir).join(".env_test");
dotenvy::from_path(env_path).ok();
// ----------------------------------------------------
let database_url = env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL must be set");
let unique_schema_name = format!(
"test_{}",
rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(12)
.map(char::from)
.collect::<String>()
);
let mut root_conn = PgConnection::connect(&database_url).await.unwrap();
root_conn
.execute(format!("CREATE SCHEMA \"{}\"", unique_schema_name).as_str())
.await
.unwrap();
let pool = PgPoolOptions::new()
.max_connections(5)
.after_connect(move |conn, _meta| {
let schema = unique_schema_name.clone();
Box::pin(async move {
conn.execute(format!("SET search_path = '{}'", schema).as_str())
.await?;
Ok(())
})
})
.connect(&database_url)
.await
.expect("Failed to create isolated pool");
sqlx::migrate!()
.run(&pool)
.await
.expect("Migrations failed in isolated schema");
sqlx::query!("INSERT INTO profiles (name) VALUES ('default') ON CONFLICT (name) DO NOTHING")
.execute(&pool)
.await
.expect("Failed to insert test profile in isolated schema");
pool
}
// ========= Fixtures for THIS FILE ONLY =========
#[fixture]
async fn pool() -> PgPool {
// This fixture now calls the LOCAL, SPECIALIZED setup function.
setup_isolated_gen_schema_db().await
}
#[fixture]
async fn closed_pool(#[future] pool: PgPool) -> PgPool {
let pool = pool.await;
pool.close().await;
pool
}
/// This fixture now works perfectly and is also isolated,
/// because it depends on the `pool` fixture above. No changes needed here!
#[fixture]
async fn pool_with_preexisting_table(#[future] pool: PgPool) -> PgPool {
let pool = pool.await;
let create_customers_req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "customers".into(),
columns: vec![ColumnDefinition {
name: "customer_name".into(),
field_type: "text".into(),
}],
indexes: vec!["customer_name".into()],
links: vec![],
};
post_table_definition(&pool, create_customers_req)
.await
.expect("Failed to create pre-requisite 'customers' table");
pool
}
// ========= Helper Functions =========
/// Checks the PostgreSQL information_schema to verify a table and its columns exist.
async fn assert_table_structure_is_correct(
pool: &PgPool,
table_name: &str,
expected_cols: &[(&str, &str)],
) {
let table_exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'gen' AND table_name = $1
)",
)
.bind(table_name)
.fetch_one(pool)
.await
.unwrap();
assert!(table_exists, "Table 'gen.{}' was not created", table_name);
for (col_name, col_type) in expected_cols {
let record = sqlx::query(
"SELECT data_type FROM information_schema.columns
WHERE table_schema = 'gen' AND table_name = $1 AND column_name = $2",
)
.bind(table_name)
.bind(col_name)
.fetch_optional(pool)
.await
.unwrap();
let found_type = record.unwrap_or_else(|| panic!("Column '{}' not found in table '{}'", col_name, table_name)).get::<String, _>("data_type");
// Handle type mappings, e.g., TEXT -> character varying, NUMERIC -> numeric
let normalized_found_type = found_type.to_lowercase();
let normalized_expected_type = col_type.to_lowercase();
assert!(
normalized_found_type.contains(&normalized_expected_type),
"Column '{}' has wrong type. Expected: {}, Found: {}",
col_name,
col_type,
found_type
);
}
}
// ========= Tests =========
#[rstest]
#[tokio::test]
async fn test_create_table_success(#[future] pool: PgPool) {
// Arrange
let pool = pool.await;
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "invoices".into(),
columns: vec![
ColumnDefinition {
name: "invoice_number".into(),
field_type: "text".into(),
},
ColumnDefinition {
name: "amount".into(),
field_type: "decimal(10, 2)".into(),
},
],
indexes: vec!["invoice_number".into()],
links: vec![],
};
// Act
let response = post_table_definition(&pool, request).await.unwrap();
// Assert
assert!(response.success);
assert!(response.sql.contains("CREATE TABLE gen.\"invoices\""));
assert!(response.sql.contains("\"invoice_number\" TEXT"));
assert!(response.sql.contains("\"amount\" NUMERIC(10, 2)"));
assert!(response
.sql
.contains("CREATE INDEX \"idx_invoices_invoice_number\""));
// Verify actual DB state
assert_table_structure_is_correct(
&pool,
"invoices",
&[
("id", "bigint"),
("deleted", "boolean"),
("invoice_number", "text"),
("amount", "numeric"),
("created_at", "timestamp with time zone"),
],
)
.await;
}
#[rstest]
#[tokio::test]
async fn test_fail_on_invalid_decimal_format(#[future] pool: PgPool) {
let pool = pool.await;
let invalid_types = vec![
"decimal(0,0)", // precision too small
"decimal(5,10)", // scale > precision
"decimal(10)", // missing scale
"decimal(a,b)", // non-numeric
];
for invalid_type in invalid_types {
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: format!("table_{}", invalid_type),
columns: vec![ColumnDefinition {
name: "amount".into(),
field_type: invalid_type.into(),
}],
..Default::default()
};
let result = post_table_definition(&pool, request).await;
assert_eq!(result.unwrap_err().code(), Code::InvalidArgument);
}
}
#[rstest]
#[tokio::test]
async fn test_create_table_with_link(
#[future] pool_with_preexisting_table: PgPool,
) {
// Arrange
let pool = pool_with_preexisting_table.await;
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "orders".into(),
columns: vec![],
indexes: vec![],
links: vec![TableLink { // CORRECTED
linked_table_name: "customers".into(),
required: true,
}],
};
// Act
let response = post_table_definition(&pool, request).await.unwrap();
// Assert
assert!(response.success);
assert!(response.sql.contains(
"\"customers_id\" BIGINT NOT NULL REFERENCES gen.\"customers\"(id)"
));
assert!(response
.sql
.contains("CREATE INDEX \"idx_orders_customers_fk\""));
// Verify actual DB state
assert_table_structure_is_correct(
&pool,
"orders",
&[("customers_id", "bigint")],
)
.await;
}
#[rstest]
#[tokio::test]
async fn test_fail_on_duplicate_table_name(#[future] pool: PgPool) {
// Arrange
let pool = pool.await;
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "reused_name".into(),
..Default::default()
};
// Create it once
post_table_definition(&pool, request.clone()).await.unwrap();
// Act: Try to create it again
let result = post_table_definition(&pool, request).await;
// Assert
let err = result.unwrap_err();
assert_eq!(err.code(), Code::AlreadyExists);
assert_eq!(err.message(), "Table already exists in this profile");
}
#[rstest]
#[tokio::test]
async fn test_fail_on_invalid_table_name(#[future] pool: PgPool) {
let pool = pool.await;
let mut request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "ends_with_id".into(), // Invalid name
..Default::default()
};
let result = post_table_definition(&pool, request.clone()).await;
assert_eq!(result.unwrap_err().code(), Code::InvalidArgument);
request.table_name = "deleted".into(); // Reserved name
let result = post_table_definition(&pool, request.clone()).await;
assert_eq!(result.unwrap_err().code(), Code::InvalidArgument);
}
#[rstest]
#[tokio::test]
async fn test_fail_on_invalid_column_type(#[future] pool: PgPool) {
// Arrange
let pool = pool.await;
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "bad_col_type".into(),
columns: vec![ColumnDefinition {
name: "some_col".into(),
field_type: "super_string_9000".into(), // Invalid type
}],
..Default::default()
};
// Act
let result = post_table_definition(&pool, request).await;
// Assert
let err = result.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("Invalid field type"));
}
#[rstest]
#[tokio::test]
async fn test_fail_on_index_for_nonexistent_column(#[future] pool: PgPool) {
// Arrange
let pool = pool.await;
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "bad_index".into(),
columns: vec![ColumnDefinition {
name: "real_column".into(),
field_type: "text".into(),
}],
indexes: vec!["fake_column".into()], // Index on a column not in the list
..Default::default()
};
// Act
let result = post_table_definition(&pool, request).await;
// Assert
let err = result.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(err.message().contains("Index column fake_column not found"));
}
#[rstest]
#[tokio::test]
async fn test_fail_on_link_to_nonexistent_table(#[future] pool: PgPool) {
// Arrange
let pool = pool.await;
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "bad_link".into(),
links: vec![TableLink { // CORRECTED
linked_table_name: "i_do_not_exist".into(),
required: false,
}],
..Default::default()
};
// Act
let result = post_table_definition(&pool, request).await;
// Assert
let err = result.unwrap_err();
assert_eq!(err.code(), Code::NotFound);
assert!(err.message().contains("Linked table i_do_not_exist not found"));
}
#[rstest]
#[tokio::test]
async fn test_database_error_on_closed_pool(
#[future] closed_pool: PgPool,
) {
// Arrange
let pool = closed_pool.await;
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "wont_be_created".into(),
..Default::default()
};
// Act
let result = post_table_definition(&pool, request).await;
// Assert
assert_eq!(result.unwrap_err().code(), Code::Internal);
}
// Tests that minimal, uppercase and whitespacepadded decimal specs
// are accepted and correctly mapped to NUMERIC(p, s).
#[rstest]
#[tokio::test]
async fn test_valid_decimal_variants(#[future] pool: PgPool) {
let pool = pool.await;
let cases = vec![
("decimal(1,1)", "NUMERIC(1, 1)"),
("decimal(1,0)", "NUMERIC(1, 0)"),
("DECIMAL(5,2)", "NUMERIC(5, 2)"),
("decimal( 5 , 2 )", "NUMERIC(5, 2)"),
];
for (i, (typ, expect)) in cases.into_iter().enumerate() {
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: format!("dec_valid_{}", i),
columns: vec![ColumnDefinition {
name: "amount".into(),
field_type: typ.into(),
}],
..Default::default()
};
let resp = post_table_definition(&pool, request).await.unwrap();
assert!(resp.success, "{}", typ);
assert!(
resp.sql.contains(expect),
"expected `{}` to map to {}, got `{}`",
typ,
expect,
resp.sql
);
}
}
// Tests that malformed decimal inputs are rejected with InvalidArgument.
#[rstest]
#[tokio::test]
async fn test_fail_on_malformed_decimal_inputs(#[future] pool: PgPool) {
let pool = pool.await;
let bad = vec!["decimal", "decimal()", "decimal(5,)", "decimal(,2)", "decimal(, )"];
for (i, typ) in bad.into_iter().enumerate() {
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: format!("dec_bad_{}", i),
columns: vec![ColumnDefinition {
name: "amt".into(),
field_type: typ.into(),
}],
..Default::default()
};
let err = post_table_definition(&pool, request).await.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument, "{}", typ);
}
}
// Tests that obviously invalid column identifiers are rejected
// (start with digit/underscore, contain space or hyphen, or are empty).
#[rstest]
#[tokio::test]
async fn test_fail_on_invalid_column_names(#[future] pool: PgPool) {
let pool = pool.await;
let bad_names = vec!["1col", "_col", "col name", "col-name", ""];
for name in bad_names {
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "tbl_invalid_cols".into(),
columns: vec![ColumnDefinition {
name: name.into(),
field_type: "text".into(),
}],
..Default::default()
};
let err = post_table_definition(&pool, request).await.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument, "{}", name);
}
}
// Tests that a usersupplied column ending in "_id" is rejected
// to avoid collision with systemgenerated FKs.
#[rstest]
#[tokio::test]
async fn test_fail_on_column_name_suffix_id(#[future] pool: PgPool) {
let pool = pool.await;
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "tbl_suffix_id".into(),
columns: vec![ColumnDefinition {
name: "user_id".into(),
field_type: "text".into(),
}],
..Default::default()
};
let err = post_table_definition(&pool, request).await.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(
err.message().to_lowercase().contains("invalid column name"),
"unexpected error message: {}",
err.message()
);
}
include!("post_table_definition_test2.rs");
include!("post_table_definition_test3.rs");
include!("post_table_definition_test4.rs");

View File

@@ -0,0 +1,490 @@
// ============================================================================
// Additional edgecase tests for PostTableDefinition
// ============================================================================
// 1) Fieldtype mapping for every predefined key, in various casing.
#[rstest]
#[tokio::test]
async fn test_field_type_mapping_various_casing(#[future] pool: PgPool) {
let pool = pool.await;
let cases = vec![
("text", "TEXT", "text"),
("TEXT", "TEXT", "text"),
("TeXt", "TEXT", "text"),
("string", "TEXT", "text"),
("boolean", "BOOLEAN", "boolean"),
("Boolean", "BOOLEAN", "boolean"),
("timestamp", "TIMESTAMPTZ", "timestamp with time zone"),
("time", "TIMESTAMPTZ", "timestamp with time zone"),
("money", "NUMERIC(14, 4)", "numeric"),
("integer", "INTEGER", "integer"),
("date", "DATE", "date"),
];
for (i, &(input, expected_sql, expected_db)) in cases.iter().enumerate() {
let tbl = format!("ftm_{}", i);
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: tbl.clone(),
columns: vec![ColumnDefinition {
name: "col".into(),
field_type: input.into(),
}],
..Default::default()
};
let resp = post_table_definition(&pool, req).await.unwrap();
assert!(
resp.sql.contains(&format!("\"col\" {}", expected_sql)),
"fieldtype {:?} did not map to {} in `{}`",
input,
expected_sql,
resp.sql
);
assert_table_structure_is_correct(
&pool,
&tbl,
&[
("id", "bigint"),
("deleted", "boolean"),
("col", expected_db),
("created_at", "timestamp with time zone"),
],
)
.await;
}
}
// 3) Invalid index names must be rejected.
#[rstest]
#[tokio::test]
async fn test_fail_on_invalid_index_names(#[future] pool: PgPool) {
let pool = pool.await;
let bad_idxs = vec!["1col", "_col", "col-name"];
for idx in bad_idxs {
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "idx_bad".into(),
columns: vec![ColumnDefinition {
name: "good".into(),
field_type: "text".into(),
}],
indexes: vec![idx.into()],
..Default::default()
};
let err = post_table_definition(&pool, req).await.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(
err
.message()
.to_lowercase()
.contains("invalid index name"),
"{:?} yielded wrong message: {}",
idx,
err.message()
);
}
}
// 4) More invalidtablename cases: starts-with digit/underscore or sanitizes to empty.
#[rstest]
#[tokio::test]
async fn test_fail_on_more_invalid_table_names(#[future] pool: PgPool) {
let pool = pool.await;
let cases = vec![
("1tbl", "invalid table name"),
("_tbl", "invalid table name"),
("!@#$", "cannot be empty"),
("__", "cannot be empty"),
];
for (name, expected_msg) in cases {
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: name.into(),
..Default::default()
};
let err = post_table_definition(&pool, req).await.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(
err.message().to_lowercase().contains(expected_msg),
"{:?} => {}",
name,
err.message()
);
}
}
// 5) Namesanitization: mixedcase table names and strip invalid characters.
#[rstest]
#[tokio::test]
async fn test_name_sanitization(#[future] pool: PgPool) {
let pool = pool.await;
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "My-Table!123".into(),
columns: vec![ColumnDefinition {
name: "User Name".into(),
field_type: "text".into(),
}],
..Default::default()
};
let resp = post_table_definition(&pool, req).await.unwrap();
assert!(
resp.sql.contains("CREATE TABLE gen.\"mytable123\""),
"{:?}",
resp.sql
);
assert!(
resp.sql.contains("\"username\" TEXT"),
"{:?}",
resp.sql
);
assert_table_structure_is_correct(
&pool,
"mytable123",
&[
("id", "bigint"),
("deleted", "boolean"),
("username", "text"),
("created_at", "timestamp with time zone"),
],
)
.await;
}
// 6) Creating a table with no custom columns, indexes, or links → only system columns.
#[rstest]
#[tokio::test]
async fn test_create_minimal_table(#[future] pool: PgPool) {
let pool = pool.await;
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "minimal".into(),
..Default::default()
};
let resp = post_table_definition(&pool, req).await.unwrap();
assert!(resp.sql.contains("id BIGSERIAL PRIMARY KEY"));
assert!(resp.sql.contains("deleted BOOLEAN NOT NULL"));
assert!(resp.sql.contains("created_at TIMESTAMPTZ"));
assert_table_structure_is_correct(
&pool,
"minimal",
&[
("id", "bigint"),
("deleted", "boolean"),
("created_at", "timestamp with time zone"),
],
)
.await;
}
// 7) Required & optional links: NOT NULL vs NULL.
#[rstest]
#[tokio::test]
async fn test_nullable_and_multiple_links(#[future] pool_with_preexisting_table: PgPool) {
let pool = pool_with_preexisting_table.await;
// create a second linktarget
let sup = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "suppliers".into(),
columns: vec![ColumnDefinition {
name: "sup_name".into(),
field_type: "text".into(),
}],
indexes: vec!["sup_name".into()],
links: vec![],
};
post_table_definition(&pool, sup).await.unwrap();
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "orders_links".into(),
columns: vec![],
indexes: vec![],
links: vec![
TableLink {
linked_table_name: "customers".into(),
required: true,
},
TableLink {
linked_table_name: "suppliers".into(),
required: false,
},
],
};
let resp = post_table_definition(&pool, req).await.unwrap();
assert!(
resp
.sql
.contains("\"customers_id\" BIGINT NOT NULL"),
"{:?}",
resp.sql
);
assert!(
resp.sql.contains("\"suppliers_id\" BIGINT"),
"{:?}",
resp.sql
);
// DBlevel nullability for optional FK
let is_nullable: String = sqlx::query_scalar!(
"SELECT is_nullable \
FROM information_schema.columns \
WHERE table_schema='gen' \
AND table_name=$1 \
AND column_name='suppliers_id'",
"orders_links"
)
.fetch_one(&pool)
.await
.unwrap()
.unwrap();
assert_eq!(is_nullable, "YES");
}
// 8) Duplicate links in one request → Internal.
#[rstest]
#[tokio::test]
async fn test_fail_on_duplicate_links(#[future] pool_with_preexisting_table: PgPool) {
let pool = pool_with_preexisting_table.await;
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "dup_links".into(),
columns: vec![],
indexes: vec![],
links: vec![
TableLink {
linked_table_name: "customers".into(),
required: true,
},
TableLink {
linked_table_name: "customers".into(),
required: false,
},
],
};
let err = post_table_definition(&pool, req).await.unwrap_err();
assert_eq!(err.code(), Code::Internal);
}
// 9) Selfreferential FK: link child back to sameprofile parent.
#[rstest]
#[tokio::test]
async fn test_self_referential_link(#[future] pool: PgPool) {
let pool = pool.await;
post_table_definition(
&pool,
PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "selfref".into(),
..Default::default()
},
)
.await
.unwrap();
let resp = post_table_definition(
&pool,
PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "selfref_child".into(),
links: vec![TableLink {
linked_table_name: "selfref".into(),
required: true,
}],
..Default::default()
},
)
.await
.unwrap();
assert!(
resp
.sql
.contains("\"selfref_id\" BIGINT NOT NULL REFERENCES gen.\"selfref\"(id)"),
"{:?}",
resp.sql
);
}
// 11) Crossprofile uniqueness & link isolation.
#[rstest]
#[tokio::test]
async fn test_cross_profile_uniqueness_and_link_isolation(#[future] pool: PgPool) {
let pool = pool.await;
// Profile A: foo
post_table_definition(&pool, PostTableDefinitionRequest {
profile_name: "A".into(),
table_name: "foo".into(),
columns: vec![ColumnDefinition { name: "col".into(), field_type: "text".into() }], // Added this
..Default::default()
}).await.unwrap();
// Profile B: foo, bar
post_table_definition(&pool, PostTableDefinitionRequest {
profile_name: "B".into(),
table_name: "foo".into(),
columns: vec![ColumnDefinition { name: "col".into(), field_type: "text".into() }], // Added this
..Default::default()
}).await.unwrap();
post_table_definition(&pool, PostTableDefinitionRequest {
profile_name: "B".into(),
table_name: "bar".into(),
columns: vec![ColumnDefinition { name: "col".into(), field_type: "text".into() }], // Added this
..Default::default()
}).await.unwrap();
// A linking to B.bar → NotFound
let err = post_table_definition(&pool, PostTableDefinitionRequest {
profile_name: "A".into(),
table_name: "linker".into(),
columns: vec![ColumnDefinition { name: "col".into(), field_type: "text".into() }], // Added this
links: vec![TableLink {
linked_table_name: "bar".into(),
required: false,
}],
..Default::default()
}).await.unwrap_err();
assert_eq!(err.code(), Code::NotFound);
}
// 12) SQLinjection attempts are sanitized.
#[rstest]
#[tokio::test]
async fn test_sql_injection_sanitization(#[future] pool: PgPool) {
let pool = pool.await;
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "users; DROP TABLE users;".into(),
columns: vec![ColumnDefinition {
name: "col\"; DROP".into(),
field_type: "text".into(),
}],
..Default::default()
};
let resp = post_table_definition(&pool, req).await.unwrap();
assert!(
resp
.sql
.contains("CREATE TABLE gen.\"usersdroptableusers\""),
"{:?}",
resp.sql
);
assert!(
resp.sql.contains("\"coldrop\" TEXT"),
"{:?}",
resp.sql
);
assert_table_structure_is_correct(
&pool,
"usersdroptableusers",
&[
("id", "bigint"),
("deleted", "boolean"),
("coldrop", "text"),
("created_at", "timestamp with time zone"),
],
)
.await;
}
// 13) Reservedcolumn shadowing: id, deleted, created_at cannot be userdefined.
#[rstest]
#[tokio::test]
async fn test_reserved_column_shadowing(#[future] pool: PgPool) {
let pool = pool.await;
for col in &["id", "deleted", "created_at"] {
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: format!("tbl_{}", col),
columns: vec![ColumnDefinition {
name: (*col).into(),
field_type: "text".into(),
}],
..Default::default()
};
let err = post_table_definition(&pool, req).await.unwrap_err();
assert_eq!(err.code(), Code::Internal, "{:?}", col);
}
}
// 14) Identifierlength overflow (>63 chars) yields Internal.
#[rstest]
#[tokio::test]
async fn test_long_identifier_length(#[future] pool: PgPool) {
let pool = pool.await;
let long = "a".repeat(64);
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: long.clone(),
columns: vec![ColumnDefinition {
name: long.clone(),
field_type: "text".into(),
}],
..Default::default()
};
let err = post_table_definition(&pool, req).await.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
}
// 15) Decimal precision overflow must be caught by our parser.
#[rstest]
#[tokio::test]
async fn test_decimal_precision_overflow(#[future] pool: PgPool) {
let pool = pool.await;
let req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "dp_overflow".into(),
columns: vec![ColumnDefinition {
name: "amount".into(),
field_type: "decimal(9999999999,1)".into(),
}],
..Default::default()
};
let err = post_table_definition(&pool, req).await.unwrap_err();
assert_eq!(err.code(), Code::InvalidArgument);
assert!(
err
.message()
.to_lowercase()
.contains("invalid precision"),
"{}",
err.message()
);
}
// 16) Repeated profile insertion only creates one profile row.
#[rstest]
#[tokio::test]
async fn test_repeated_profile_insertion(#[future] pool: PgPool) {
let pool = pool.await;
let prof = "repeat_prof";
post_table_definition(
&pool,
PostTableDefinitionRequest {
profile_name: prof.into(),
table_name: "t1".into(),
..Default::default()
},
)
.await
.unwrap();
post_table_definition(
&pool,
PostTableDefinitionRequest {
profile_name: prof.into(),
table_name: "t2".into(),
..Default::default()
},
)
.await
.unwrap();
let cnt: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) FROM profiles WHERE name = $1",
prof
)
.fetch_one(&pool)
.await
.unwrap()
.unwrap();
assert_eq!(cnt, 1);
}

View File

@@ -0,0 +1,242 @@
// tests/table_definition/post_table_definition_test3.rs
// NOTE: All 'use' statements have been removed from this file.
// They are inherited from the parent file that includes this one.
// ========= Helper Functions for this Test File =========
/// Checks that a table definition does NOT exist for a given profile and table name.
async fn assert_table_definition_does_not_exist(pool: &PgPool, profile_name: &str, table_name: &str) {
let count: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) FROM table_definitions td
JOIN profiles p ON td.profile_id = p.id
WHERE p.name = $1 AND td.table_name = $2",
profile_name,
table_name
)
.fetch_one(pool)
.await
.expect("Failed to query for table definition")
.unwrap_or(0);
assert_eq!(
count, 0,
"Table definition for '{}/{}' was found but should have been rolled back.",
profile_name, table_name
);
}
// ========= Category 2: Advanced Identifier and Naming Collisions =========
#[rstest]
#[tokio::test]
async fn test_fail_on_column_name_collision_with_fk(
#[future] pool_with_preexisting_table: PgPool,
) {
// Scenario: Create a table that links to 'customers' and also defines its own 'customers_id' column.
// Expected: The generated CREATE TABLE will have a duplicate column, causing a database error.
let pool = pool_with_preexisting_table.await; // Provides 'customers' table
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "orders_collision".into(),
columns: vec![ColumnDefinition {
name: "customers_id".into(), // This will collide with the generated FK
field_type: "integer".into(),
}],
links: vec![TableLink {
linked_table_name: "customers".into(),
required: true,
}],
indexes: vec![],
};
// Act
let result = post_table_definition(&pool, request).await;
// Assert
let err = result.unwrap_err();
assert_eq!(
err.code(),
Code::Internal,
"Expected Internal error due to duplicate column in CREATE TABLE"
);
}
#[rstest]
#[tokio::test]
async fn test_fail_on_duplicate_column_names_in_request(#[future] pool: PgPool) {
// Scenario: The request itself contains two columns with the same name.
// Expected: Database error on CREATE TABLE with duplicate column definition.
let pool = pool.await;
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "duplicate_cols".into(),
columns: vec![
ColumnDefinition {
name: "product_name".into(),
field_type: "text".into(),
},
ColumnDefinition {
name: "product_name".into(),
field_type: "text".into(),
},
],
..Default::default()
};
// Act
let result = post_table_definition(&pool, request).await;
// Assert
let err = result.unwrap_err();
assert_eq!(err.code(), Code::Internal);
}
#[rstest]
#[tokio::test]
async fn test_link_to_sanitized_table_name(#[future] pool: PgPool) {
// Scenario: Test that linking requires using the sanitized name, not the original.
let pool = pool.await;
let original_name = "My Invoices";
let sanitized_name = "myinvoices";
// 1. Create the table with a name that requires sanitization.
let create_req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: original_name.into(),
..Default::default()
};
let resp = post_table_definition(&pool, create_req).await.unwrap();
assert!(resp.sql.contains(&format!("gen.\"{}\"", sanitized_name)));
// 2. Attempt to link to the *original* name, which should fail.
let link_req_fail = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "payments".into(),
links: vec![TableLink {
linked_table_name: original_name.into(),
required: true,
}],
..Default::default()
};
let err = post_table_definition(&pool, link_req_fail)
.await
.unwrap_err();
assert_eq!(err.code(), Code::NotFound);
assert!(err.message().contains("Linked table My Invoices not found"));
// 3. Attempt to link to the *sanitized* name, which should succeed.
let link_req_success = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "payments_sanitized".into(),
links: vec![TableLink {
linked_table_name: sanitized_name.into(),
required: true,
}],
..Default::default()
};
let success_resp = post_table_definition(&pool, link_req_success).await.unwrap();
assert!(success_resp.success);
assert!(success_resp
.sql
.contains(&format!("REFERENCES gen.\"{}\"(id)", sanitized_name)));
}
// ========= Category 3: Complex Link and Profile Logic =========
#[rstest]
#[tokio::test]
async fn test_fail_on_true_self_referential_link(#[future] pool: PgPool) {
// Scenario: A table attempts to link to itself in the same request.
// Expected: NotFound, because the table definition doesn't exist yet at link-check time.
let pool = pool.await;
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "employees".into(),
links: vec![TableLink {
linked_table_name: "employees".into(), // Self-reference
required: false, // For a manager_id FK
}],
..Default::default()
};
// Act
let result = post_table_definition(&pool, request).await;
// Assert
let err = result.unwrap_err();
assert_eq!(err.code(), Code::NotFound);
assert!(err.message().contains("Linked table employees not found"));
}
#[rstest]
#[tokio::test]
async fn test_behavior_on_empty_profile_name(#[future] pool: PgPool) {
// Scenario: Attempt to create a table with an empty profile name.
// Expected: This should be rejected by input validation.
let pool = pool.await;
let request = PostTableDefinitionRequest {
profile_name: "".into(),
table_name: "table_in_empty_profile".into(),
..Default::default()
};
// Act
let result = post_table_definition(&pool, request).await;
// Assert
let err = result.unwrap_err();
assert_eq!(
err.code(),
Code::InvalidArgument, // Changed from Internal
"Expected InvalidArgument error from input validation"
);
assert!(
err.message().contains("Profile name cannot be empty"), // Updated message
"Unexpected error message: {}",
err.message()
);
}
// ========= Category 4: Concurrency =========
#[rstest]
#[tokio::test]
#[ignore = "Concurrency tests can be flaky and require careful setup"]
async fn test_race_condition_on_table_creation(#[future] pool: PgPool) {
// Scenario: Two requests try to create the exact same table at the same time.
// Expected: One succeeds, the other fails with AlreadyExists.
let pool = pool.await;
let request1 = PostTableDefinitionRequest {
profile_name: "concurrent_profile".into(),
table_name: "racy_table".into(),
..Default::default()
};
let request2 = request1.clone();
let pool1 = pool.clone();
let pool2 = pool.clone();
// Act
let (res1, res2) = tokio::join!(
post_table_definition(&pool1, request1),
post_table_definition(&pool2, request2)
);
// Assert
let results = vec![res1, res2];
let success_count = results.iter().filter(|r| r.is_ok()).count();
let failure_count = results.iter().filter(|r| r.is_err()).count();
assert_eq!(
success_count, 1,
"Exactly one request should succeed"
);
assert_eq!(failure_count, 1, "Exactly one request should fail");
let err = results
.into_iter()
.find(|r| r.is_err())
.unwrap()
.unwrap_err();
assert_eq!(err.code(), Code::AlreadyExists);
assert_eq!(err.message(), "Table already exists in this profile");
}

View File

@@ -0,0 +1,222 @@
// tests/table_definition/post_table_definition_test4.rs
// NOTE: All 'use' statements are inherited from the parent file that includes this one.
// ========= Category 5: Implementation-Specific Edge Cases =========
#[rstest]
#[tokio::test]
async fn test_fail_on_fk_base_name_collision(#[future] pool: PgPool) {
// Scenario: Link to two tables (`team1_users`, `team2_users`) that both have a
// base name of "users". This should cause a duplicate "users_id" column in the
// generated SQL.
let pool = pool.await;
// Arrange: Create the two prerequisite tables
let req1 = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "team1_users".into(),
..Default::default()
};
post_table_definition(&pool, req1).await.unwrap();
let req2 = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "team2_users".into(),
..Default::default()
};
post_table_definition(&pool, req2).await.unwrap();
// Arrange: A request that links to both, causing the collision
let colliding_req = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "tasks".into(),
links: vec![
TableLink {
linked_table_name: "team1_users".into(),
required: true,
},
TableLink {
linked_table_name: "team2_users".into(),
required: false,
},
],
..Default::default()
};
// Act
let result = post_table_definition(&pool, colliding_req).await;
// Assert
let err = result.unwrap_err();
assert_eq!(
err.code(),
Code::Internal,
"Expected Internal error from duplicate column in CREATE TABLE"
);
}
#[rstest]
#[tokio::test]
async fn test_sql_reserved_keywords_as_identifiers_are_allowed(#[future] pool: PgPool) {
// NOTE: This test confirms that the system currently allows SQL reserved keywords
// as column names because they are correctly quoted. This is technically correct,
// but some systems add validation to block this as a policy to prevent user confusion.
let pool = pool.await;
let keywords = vec!["user", "select", "group", "order"];
for (i, keyword) in keywords.into_iter().enumerate() {
let table_name = format!("keyword_test_{}", i);
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: table_name.clone(),
columns: vec![ColumnDefinition {
name: keyword.into(),
field_type: "text".into(),
}],
..Default::default()
};
// Act & Assert
let response = post_table_definition(&pool, request)
.await
.unwrap_or_else(|e| {
panic!(
"Failed to create table with reserved keyword '{}': {:?}",
keyword, e
)
});
assert!(response.success);
assert!(response.sql.contains(&format!("\"{}\" TEXT", keyword)));
assert_table_structure_is_correct(&pool, &table_name, &[(keyword, "text")]).await;
}
}
// ========= Category 6: Environmental and Extreme Edge Cases =========
#[rstest]
#[tokio::test]
async fn test_sanitization_of_unicode_and_special_chars(#[future] pool: PgPool) {
// Scenario: Use identifiers with characters that should be stripped by sanitization,
// including multi-byte unicode (emoji) and a null byte.
let pool = pool.await;
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "produits_😂".into(), // Should become "produits_"
columns: vec![ColumnDefinition {
name: "col\0with_null".into(), // Should become "colwith_null"
field_type: "text".into(),
}],
..Default::default()
};
// Act
let response = post_table_definition(&pool, request).await.unwrap();
// Assert
assert!(response.success);
// Assert that the generated SQL contains the SANITIZED names
assert!(response.sql.contains("CREATE TABLE gen.\"produits_\""));
assert!(response.sql.contains("\"colwith_null\" TEXT"));
// Verify the actual structure in the database
assert_table_structure_is_correct(&pool, "produits_", &[("colwith_null", "text")]).await;
}
#[rstest]
#[tokio::test]
async fn test_fail_gracefully_if_schema_is_missing(#[future] pool: PgPool) {
// Scenario: The handler relies on the 'gen' schema existing. This test ensures
// it fails gracefully if that assumption is broken.
let pool = pool.await;
// Arrange: Drop the schema that the handler needs
sqlx::query("DROP SCHEMA gen CASCADE;")
.execute(&pool)
.await
.expect("Failed to drop 'gen' schema for test setup");
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "this_will_fail".into(),
..Default::default()
};
// Act
let result = post_table_definition(&pool, request).await;
// Assert
let err = result.unwrap_err();
assert_eq!(err.code(), Code::Internal);
// Check for the Postgres error message for a missing schema.
assert!(err.message().to_lowercase().contains("schema \"gen\" does not exist"));
}
#[rstest]
#[tokio::test]
async fn test_column_name_with_id_suffix_is_rejected(#[future] pool: PgPool) {
// Test that column names ending with '_id' are properly rejected during input validation
let pool = pool.await;
// Test 1: Column ending with '_id' should be rejected
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "orders".into(), // Valid table name
columns: vec![ColumnDefinition {
name: "legacy_order_id".into(), // This should be rejected
field_type: "integer".into(),
}],
..Default::default()
};
// Act & Assert - should fail validation
let result = post_table_definition(&pool, request).await;
assert!(result.is_err(), "Column names ending with '_id' should be rejected");
if let Err(status) = result {
assert_eq!(status.code(), tonic::Code::InvalidArgument);
assert!(status.message().contains("Invalid column name"));
}
// Test 2: Column named exactly 'id' should be rejected
let request2 = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "orders".into(),
columns: vec![ColumnDefinition {
name: "id".into(), // This should be rejected
field_type: "integer".into(),
}],
..Default::default()
};
let result2 = post_table_definition(&pool, request2).await;
assert!(result2.is_err(), "Column named 'id' should be rejected");
}
#[rstest]
#[tokio::test]
async fn test_table_name_with_id_suffix_is_rejected(#[future] pool: PgPool) {
// Test that table names ending with '_id' are properly rejected during input validation
let pool = pool.await;
let request = PostTableDefinitionRequest {
profile_name: "default".into(),
table_name: "orders_id".into(), // This should be rejected
columns: vec![ColumnDefinition {
name: "customer_name".into(), // Valid column name
field_type: "text".into(),
}],
..Default::default()
};
// Act & Assert - should fail validation
let result = post_table_definition(&pool, request).await;
assert!(result.is_err(), "Table names ending with '_id' should be rejected");
if let Err(status) = result {
assert_eq!(status.code(), tonic::Code::InvalidArgument);
assert!(status.message().contains("Table name cannot be 'id', 'deleted', 'created_at' or end with '_id'"));
}
}