Skip to content

Commit a018c86

Browse files
committed
v0.8.7: fix quoting & SQLite JSON decoding
Bump crate versions to 0.8.7 and harden SQL identifier handling and SQLite JSON decoding. Add a dedicated identifier-quoting path (format_identifier_reference) and simplify column formatting to consistently quote reserved identifiers (SELECT, WHERE, GROUP BY, JOIN, ORDER BY, aliases, and direction). Preserve embedded quote escaping and tighten reserved-word quoting behavior. Fix SQLite raw JSON decoding for untyped aggregate results (e.g. COUNT/SUM) so numeric aggregates are returned as numbers instead of strings. Add unit/tests and regression tests for identifier quoting and SQLite aggregate JSON decoding. Update docs, README examples, CHANGELOG, and macro crate metadata accordingly.
1 parent 6aa0e42 commit a018c86

13 files changed

Lines changed: 232 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,24 @@ All notable changes to TideORM will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.8.7] - 2026-03-21
9+
10+
### Fixed
11+
12+
- Quoted simple identifier references consistently in the manual SQL builder paths so reserved column names such as `order` and `group` no longer break generated SELECT, WHERE, GROUP BY, JOIN, and ORDER BY clauses.
13+
- Preserved embedded identifier-quote escaping while tightening the reserved-word quoting path, so names containing quote characters still render correctly for each backend dialect.
14+
- Fixed SQLite raw JSON decoding for untyped aggregate expressions such as `COUNT(*)` and `SUM(...)`, so count/exists helpers and raw JSON reads no longer degrade numeric aggregate results into strings.
15+
16+
### Changed
17+
18+
- Refreshed dependency examples and macro-crate docs to use the 0.8.7 release version.
19+
20+
### Internal
21+
22+
- Added query SQL regressions covering reserved-word identifiers and embedded quote escaping.
23+
- Added SQLite raw JSON regressions covering aggregate count decoding.
24+
- Verified the release prep with `cargo test --all-features`, `cargo clippy --lib --all-features -- -D warnings`, and `mdbook build`.
25+
826
## [0.8.6] - 2026-03-21
927

1028
### Fixed
@@ -606,7 +624,8 @@ This is the first public release of TideORM, a developer-friendly ORM for Rust w
606624
- **Repository:** [https://github.com/mohamadzoh/tideorm](https://github.com/mohamadzoh/tideorm)
607625
- **Documentation:** See README.md and examples/
608626

609-
[Unreleased]: https://github.com/mohamadzoh/tideorm/compare/v0.8.6...HEAD
627+
[Unreleased]: https://github.com/mohamadzoh/tideorm/compare/v0.8.7...HEAD
628+
[0.8.7]: https://github.com/mohamadzoh/tideorm/compare/v0.8.6...v0.8.7
610629
[0.8.6]: https://github.com/mohamadzoh/tideorm/compare/v0.8.5...v0.8.6
611630
[0.8.5]: https://github.com/mohamadzoh/tideorm/compare/v0.8.4...v0.8.5
612631
[0.8.4]: https://github.com/mohamadzoh/tideorm/compare/v0.8.1...v0.8.4

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "tideorm"
3-
version = "0.8.6"
3+
version = "0.8.7"
44
edition = "2021"
55
authors = ["Mohamad Al Zohbie <alzoubi528@gmail.com>"]
66
description = "A developer-friendly ORM for Rust with clean, expressive syntax"
@@ -48,7 +48,7 @@ rust_decimal = { version = "1.40.0", features = ["serde"] }
4848
thiserror = "2.0.18"
4949

5050
# Derive macros (our own crate)
51-
tideorm-macros = { version = "0.8.6", path = "tideorm-macros" }
51+
tideorm-macros = { version = "0.8.7", path = "tideorm-macros" }
5252

5353
# Utils
5454
parking_lot = "0.12.5"

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -192,22 +192,22 @@ let recent_posts = user.posts.load_with(|q| {
192192
```toml
193193
[dependencies]
194194
# PostgreSQL (default)
195-
tideorm = { version = "0.8.6", features = ["postgres"] }
195+
tideorm = { version = "0.8.7", features = ["postgres"] }
196196

197197
# MySQL
198-
tideorm = { version = "0.8.6", features = ["mysql"] }
198+
tideorm = { version = "0.8.7", features = ["mysql"] }
199199

200200
# SQLite
201-
tideorm = { version = "0.8.6", features = ["sqlite"] }
201+
tideorm = { version = "0.8.7", features = ["sqlite"] }
202202

203203
# Enable attachments support explicitly
204-
tideorm = { version = "0.8.6", features = ["postgres", "attachments"] }
204+
tideorm = { version = "0.8.7", features = ["postgres", "attachments"] }
205205

206206
# Enable translations support explicitly
207-
tideorm = { version = "0.8.6", features = ["postgres", "translations"] }
207+
tideorm = { version = "0.8.7", features = ["postgres", "translations"] }
208208

209209
# Enable full-text search support explicitly
210-
tideorm = { version = "0.8.6", features = ["postgres", "fulltext"] }
210+
tideorm = { version = "0.8.7", features = ["postgres", "fulltext"] }
211211
```
212212

213213
### Feature Flags

docs/queries.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ TideORM provides full-text search capabilities across PostgreSQL (tsvector/tsque
514514
Enable the feature explicitly when you need the full-text search API:
515515

516516
```toml
517-
tideorm = { version = "0.8.6", features = ["postgres", "fulltext"] }
517+
tideorm = { version = "0.8.7", features = ["postgres", "fulltext"] }
518518
```
519519

520520
### Search Basics

docs/relations.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ Enable the feature first:
210210

211211
```toml
212212
[dependencies]
213-
tideorm = { version = "0.8.6", features = ["postgres", "attachments"] }
213+
tideorm = { version = "0.8.7", features = ["postgres", "attachments"] }
214214
```
215215

216216
### Model Setup
@@ -545,7 +545,7 @@ Enable the feature first:
545545

546546
```toml
547547
[dependencies]
548-
tideorm = { version = "0.8.6", features = ["postgres", "translations"] }
548+
tideorm = { version = "0.8.7", features = ["postgres", "translations"] }
549549
```
550550

551551
### Model Setup

src/database.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -880,7 +880,7 @@ impl Database {
880880
}
881881
"TEXT" => Self::typed_or_fallback::<String>(result, index),
882882
"BLOB" => Self::typed_or_fallback::<Vec<u8>>(result, index),
883-
_ => Self::fallback_try_get_json(result, index),
883+
_ => Self::sqlite_unknown_type_or_fallback(result, index),
884884
}
885885
});
886886
}
@@ -905,10 +905,10 @@ impl Database {
905905
.or_else(|| Self::try_get_json::<chrono::NaiveDateTime>(row, index))
906906
.or_else(|| Self::try_get_json::<chrono::NaiveDate>(row, index))
907907
.or_else(|| Self::try_get_json::<chrono::NaiveTime>(row, index))
908-
.or_else(|| Self::try_get_json::<bool>(row, index))
909908
.or_else(|| Self::try_get_json::<i64>(row, index))
910909
.or_else(|| Self::try_get_json::<u64>(row, index))
911910
.or_else(|| Self::try_get_json::<f64>(row, index))
911+
.or_else(|| Self::try_get_json::<bool>(row, index))
912912
.or_else(|| Self::try_get_json::<String>(row, index))
913913
.unwrap_or(serde_json::Value::Null)
914914
}
@@ -926,6 +926,26 @@ impl Database {
926926
.unwrap_or_else(|| Self::fallback_try_get_json(row, index))
927927
}
928928

929+
#[cfg(feature = "sqlite")]
930+
fn sqlite_unknown_type_or_fallback(
931+
row: &crate::internal::QueryResult,
932+
index: usize,
933+
) -> serde_json::Value {
934+
let value = Self::fallback_try_get_json(row, index);
935+
936+
if let serde_json::Value::String(text) = &value {
937+
if let Ok(integer) = text.parse::<i64>() {
938+
return serde_json::json!(integer);
939+
}
940+
941+
if let Ok(unsigned) = text.parse::<u64>() {
942+
return serde_json::json!(unsigned);
943+
}
944+
}
945+
946+
value
947+
}
948+
929949
fn try_get_decimal_json(
930950
row: &crate::internal::QueryResult,
931951
index: usize,

src/query/db_sql.rs

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,36 @@ pub fn quote_ident(db_type: DatabaseType, name: &str) -> String {
238238
format!("{}{}{}", q, escaped, q)
239239
}
240240

241+
/// Quote a simple identifier reference like `column` or `table.column`.
242+
pub fn format_identifier_reference(db_type: DatabaseType, value: &str) -> Option<String> {
243+
let trimmed = value.trim();
244+
if trimmed.is_empty()
245+
|| trimmed.starts_with('"')
246+
|| trimmed.ends_with('"')
247+
|| trimmed.starts_with('`')
248+
|| trimmed.ends_with('`')
249+
|| trimmed.contains('(')
250+
|| trimmed.contains(')')
251+
|| trimmed.contains('*')
252+
|| trimmed.contains(' ')
253+
{
254+
return None;
255+
}
256+
257+
let parts: Vec<&str> = trimmed.split('.').collect();
258+
if parts.iter().any(|part| part.is_empty()) {
259+
return None;
260+
}
261+
262+
Some(
263+
parts
264+
.into_iter()
265+
.map(|part| quote_ident(db_type, part))
266+
.collect::<Vec<_>>()
267+
.join("."),
268+
)
269+
}
270+
241271
/// Generate JSON contains expression
242272
///
243273
/// - PostgreSQL: `column @> 'value'`
@@ -497,22 +527,7 @@ pub fn array_overlaps(db_type: DatabaseType, column: &str, values: &[String]) ->
497527

498528
/// Format a column identifier for the database
499529
pub fn format_column(db_type: DatabaseType, column: &str) -> String {
500-
if column.contains('(') || column.contains('*') {
501-
column.to_string()
502-
} else if column.contains('.') {
503-
let parts: Vec<&str> = column.split('.').collect();
504-
if parts.len() == 2 {
505-
format!(
506-
"{}.{}",
507-
quote_ident(db_type, parts[0]),
508-
quote_ident(db_type, parts[1])
509-
)
510-
} else {
511-
column.to_string()
512-
}
513-
} else {
514-
quote_ident(db_type, column)
515-
}
530+
format_identifier_reference(db_type, column).unwrap_or_else(|| column.to_string())
516531
}
517532

518533
/// Generate aggregate function with proper casting for the database

src/query/sql.rs

Lines changed: 67 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -872,10 +872,72 @@ impl<M: Model> QueryBuilder<M> {
872872
}
873873

874874
fn format_column_for_db(&self, db_type: DatabaseType, column: &str) -> String {
875-
if column.contains(' ') {
876-
column.to_string()
877-
} else {
878-
db_sql::format_column(db_type, column)
875+
let trimmed = column.trim();
876+
let parts: Vec<&str> = trimmed.split_whitespace().collect();
877+
878+
match parts.as_slice() {
879+
[identifier] => db_sql::format_column(db_type, identifier),
880+
[identifier, direction]
881+
if direction.eq_ignore_ascii_case("asc")
882+
|| direction.eq_ignore_ascii_case("desc") =>
883+
{
884+
db_sql::format_identifier_reference(db_type, identifier)
885+
.map(|identifier| format!("{} {}", identifier, direction.to_ascii_uppercase()))
886+
.unwrap_or_else(|| trimmed.to_string())
887+
}
888+
[identifier, as_keyword, alias] if as_keyword.eq_ignore_ascii_case("as") => {
889+
match (
890+
db_sql::format_identifier_reference(db_type, identifier),
891+
db_sql::format_identifier_reference(db_type, alias),
892+
) {
893+
(Some(identifier), Some(alias)) => format!("{} AS {}", identifier, alias),
894+
_ => trimmed.to_string(),
895+
}
896+
}
897+
_ => trimmed.to_string(),
898+
}
899+
}
900+
901+
fn format_select_column_for_db(&self, db_type: DatabaseType, table: &str, column: &str) -> String {
902+
let trimmed = column.trim();
903+
let parts: Vec<&str> = trimmed.split_whitespace().collect();
904+
905+
match parts.as_slice() {
906+
[identifier]
907+
if !identifier.contains('(')
908+
&& !identifier.contains('*')
909+
&& db_sql::format_identifier_reference(db_type, identifier).is_some() =>
910+
{
911+
if identifier.contains('.') {
912+
self.format_column_for_db(db_type, identifier)
913+
} else {
914+
format!(
915+
"{}.{}",
916+
db_sql::quote_ident(db_type, table),
917+
db_sql::quote_ident(db_type, identifier)
918+
)
919+
}
920+
}
921+
[identifier, as_keyword, alias]
922+
if as_keyword.eq_ignore_ascii_case("as")
923+
&& !identifier.contains('(')
924+
&& !identifier.contains('*')
925+
&& db_sql::format_identifier_reference(db_type, identifier).is_some()
926+
&& db_sql::format_identifier_reference(db_type, alias).is_some() =>
927+
{
928+
let identifier = if identifier.contains('.') {
929+
self.format_column_for_db(db_type, identifier)
930+
} else {
931+
format!(
932+
"{}.{}",
933+
db_sql::quote_ident(db_type, table),
934+
db_sql::quote_ident(db_type, identifier)
935+
)
936+
};
937+
938+
format!("{} AS {}", identifier, db_sql::quote_ident(db_type, alias))
939+
}
940+
_ => trimmed.to_string(),
879941
}
880942
}
881943

@@ -893,24 +955,7 @@ impl<M: Model> QueryBuilder<M> {
893955
if let Some(columns) = &self.select_columns {
894956
let mut rendered_columns: Vec<String> = columns
895957
.iter()
896-
.map(|column| {
897-
if column.contains('(')
898-
|| column.contains('*')
899-
|| column.contains('"')
900-
|| column.contains('`')
901-
|| column.contains(' ')
902-
{
903-
column.clone()
904-
} else if column.contains('.') {
905-
self.format_column_for_db(db_type, column)
906-
} else {
907-
format!(
908-
"{}.{}",
909-
db_sql::quote_ident(db_type, table),
910-
db_sql::quote_ident(db_type, column)
911-
)
912-
}
913-
})
958+
.map(|column| self.format_select_column_for_db(db_type, table, column))
914959
.collect();
915960

916961
for window_function in &self.window_functions {

src/testing/database_tests.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,43 @@ async fn raw_json_preserves_decimal_and_datetime_column_types() {
193193
})]
194194
);
195195
}
196+
197+
#[cfg(all(feature = "sqlite", feature = "runtime-tokio"))]
198+
#[tokio::test]
199+
async fn raw_json_preserves_count_aggregates_as_numbers() {
200+
let db = Database::connect("sqlite::memory:")
201+
.await
202+
.expect("sqlite in-memory connection should succeed");
203+
204+
db.__execute_with_params(
205+
"CREATE TABLE raw_json_count_probe (enabled BOOLEAN NOT NULL)",
206+
vec![],
207+
)
208+
.await
209+
.expect("creating count probe table should succeed");
210+
211+
for enabled in [true, true, false] {
212+
db.__execute_with_params(
213+
"INSERT INTO raw_json_count_probe (enabled) VALUES (?)",
214+
vec![crate::internal::Value::Bool(Some(enabled))],
215+
)
216+
.await
217+
.expect("inserting count probe row should succeed");
218+
}
219+
220+
let rows = db
221+
.__raw_json_with_params(
222+
"SELECT COUNT(*) AS count, SUM(enabled) AS enabled_total FROM raw_json_count_probe",
223+
vec![],
224+
)
225+
.await
226+
.expect("querying count aggregate JSON rows should succeed");
227+
228+
assert_eq!(
229+
rows,
230+
vec![serde_json::json!({
231+
"count": 3,
232+
"enabled_total": 2,
233+
})]
234+
);
235+
}

0 commit comments

Comments
 (0)