Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ when the database runs in a container. For local Oracle runs, add
`-- --test-threads=2` to avoid overwhelming the listener. Check affected matrix results for SQL
execution changes; SQLite alone cannot establish portability.

ODBC databases can also run on sqlx 0.9, through the [sqlx-odbc](https://github.com/lovasoa/sqlx-odbc) driver. This is the unstable migration path for [#1056](https://github.com/sqlpage/SQLPage/issues/1056), and releases do not include it. To use it, build with the `unstable-sqlx-next` feature and set `SQLPAGE_UNSTABLE_SQLX_NEXT=true`. With this driver, migrations, `sqlpage_files` and CSV imports stop with an error that names the missing feature.

```bash
SQLPAGE_UNSTABLE_SQLX_NEXT=true DATABASE_URL='Driver=DuckDB;Database=/tmp/sqlpage-test.duckdb' cargo test --features unstable-sqlx-next
```

## Submitting a change

Follow nearby code and tests, add regression coverage, and describe the resulting
Expand Down
109 changes: 109 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ tokio-util = "0.7.12"
openidconnect = { version = "4.0.0", default-features = false, features = ["accept-rfc3339-timestamps"] }
encoding_rs = "0.8.35"
odbc-sys = { version = "0", optional = true }
sqlx-next = { package = "sqlx", version = "0.9", default-features = false, features = ["runtime-tokio"], optional = true }
sqlx-odbc = { version = "0.0.3", default-features = false, features = ["bigdecimal", "chrono"], optional = true }
regex = "1"
lettre = { version = "0.11", default-features = false, features = [
"aws-lc-rs",
Expand All @@ -137,6 +139,7 @@ opentelemetry-semantic-conventions = { version = "0.33", features = ["semconv_ex

[features]
default = []
unstable-sqlx-next = ["dep:sqlx-next", "dep:sqlx-odbc"]
odbc-static = ["odbc-sys", "odbc-sys/vendored-unix-odbc"]
lambda-web = [
"dep:actix-http",
Expand Down
11 changes: 11 additions & 0 deletions src/app_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ pub struct AppConfig {
/// A separate field for the database password. If set, this will override any password specified in the `database_url`.
#[serde(default)]
pub database_password: Option<String>,
/// Connects to SQL Server and ODBC databases through sqlx 0.9 instead of sqlx-oldapi.
#[cfg(feature = "unstable-sqlx-next")]
#[serde(default)]
pub unstable_sqlx_next: bool,
pub max_database_pool_connections: Option<u32>,
#[serde(
default,
Expand Down Expand Up @@ -813,16 +817,23 @@ pub fn test_database_url() -> String {
std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite::memory:".to_string())
}

#[must_use]
pub fn test_unstable_sqlx_next() -> bool {
std::env::var("SQLPAGE_UNSTABLE_SQLX_NEXT").is_ok_and(|value| value == "true")
}

#[cfg(test)]
pub mod tests {
use super::AppConfig;
pub use super::test_database_url;
use super::test_unstable_sqlx_next;

#[must_use]
pub fn test_config() -> AppConfig {
let mut config = serde_json::from_str::<AppConfig>(
&serde_json::json!({
"database_url": test_database_url(),
"unstable_sqlx_next": test_unstable_sqlx_next(),
"listen_on": "localhost:8080"
})
.to_string(),
Expand Down
51 changes: 26 additions & 25 deletions src/filesystem.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use crate::webserver::ErrorWithStatus;
use crate::webserver::database::SupportedDatabase;
use crate::webserver::database::driver::unsupported_by_sqlx_next;
use crate::webserver::{Database, StatusCodeResultExt, make_placeholder};
use crate::{AppState, TEMPLATES_DIR};
use anyhow::Context;
use chrono::{DateTime, Utc};
use sqlx::any::{AnyStatement, AnyTypeInfo};
use sqlx::any::{AnyPool, AnyStatement, AnyTypeInfo};
use sqlx::executor::Executor;
use sqlx::postgres::types::PgTimeTz;
use sqlx::{postgres::Postgres, statement::Statement, types::Type};
Expand Down Expand Up @@ -85,9 +86,7 @@ impl FileSystem {
(Ok(modified), _) => Ok(modified),
(Err(e), Some(db_fs)) if is_path_missing_error(&e) => {
// no local file, try the database
db_fs
.file_modified_since_in_db(app_state, path, since)
.await
db_fs.file_modified_since_in_db(path, since).await
}
(Err(e), _) => {
let status = io_error_status(&local_path, &e).await;
Expand Down Expand Up @@ -143,7 +142,7 @@ impl FileSystem {
(Ok(f), _) => Ok(f),
(Err(e), Some(db_fs)) if is_path_missing_error(&e) => {
// no local file, try the database
db_fs.read_file(app_state, path.as_ref()).await
db_fs.read_file(path.as_ref()).await
}
(Err(e), _) => {
let status = io_error_status(&local_path, &e).await;
Expand Down Expand Up @@ -201,7 +200,7 @@ impl FileSystem {
path.display()
);
if let Some(db_fs) = &self.db_fs_queries {
return db_fs.file_exists(app_state, path).await;
return db_fs.file_exists(path).await;
}
}
Ok(local_exists)
Expand Down Expand Up @@ -313,6 +312,7 @@ async fn file_modified_since_local(path: &Path, since: DateTime<Utc>) -> tokio::
}

pub struct DbFsQueries {
pool: AnyPool,
was_modified: AnyStatement<'static>,
read_file: AnyStatement<'static>,
exists: AnyStatement<'static>,
Expand All @@ -339,17 +339,22 @@ impl DbFsQueries {

async fn init(db: &Database) -> anyhow::Result<Self> {
log::debug!("Initializing database filesystem queries");
Self::check_table_available(db).await?;
let pool = db
.connection
.as_old_api()
.ok_or_else(|| unsupported_by_sqlx_next("database-backed files"))?
.clone();
Self::check_table_available(&pool).await?;
Ok(Self {
pool,
was_modified: Self::make_was_modified_query(db).await?,
read_file: Self::make_read_file_query(db).await?,
exists: Self::make_exists_query(db).await?,
})
}

async fn check_table_available(db: &Database) -> anyhow::Result<()> {
db.connection
.execute("SELECT 1 FROM sqlpage_files WHERE 1 = 0")
async fn check_table_available(pool: &AnyPool) -> anyhow::Result<()> {
pool.execute("SELECT 1 FROM sqlpage_files WHERE 1 = 0")
.await
.map(|_| ())
.context("Unable to access sqlpage_files")?;
Expand Down Expand Up @@ -391,7 +396,6 @@ impl DbFsQueries {

async fn file_modified_since_in_db(
&self,
app_state: &AppState,
path: &Path,
since: DateTime<Utc>,
) -> anyhow::Result<bool> {
Expand All @@ -409,28 +413,25 @@ impl DbFsQueries {
self.was_modified.sql(),
(since, path)
);
let was_modified_i32 = query
.fetch_optional(&app_state.db.connection)
.await
.with_context(|| {
format!(
"Unable to check when {} was last modified in the database",
path.display()
)
})?;
let was_modified_i32 = query.fetch_optional(&self.pool).await.with_context(|| {
format!(
"Unable to check when {} was last modified in the database",
path.display()
)
})?;
log::trace!(
"DB File {} was modified result: {was_modified_i32:?}",
path.display()
);
Ok(was_modified_i32 == Some((1,)))
}

async fn read_file(&self, app_state: &AppState, path: &Path) -> anyhow::Result<Vec<u8>> {
async fn read_file(&self, path: &Path) -> anyhow::Result<Vec<u8>> {
log::debug!("Reading file {} from the database", path.display());
self.read_file
.query_as::<(Vec<u8>,)>()
.bind(path.display().to_string())
.fetch_optional(&app_state.db.connection)
.fetch_optional(&self.pool)
.await
.map_err(anyhow::Error::from)
.and_then(|modified| {
Expand All @@ -446,7 +447,7 @@ impl DbFsQueries {
.with_context(|| format!("Unable to read {} from the database", path.display()))
}

async fn file_exists(&self, app_state: &AppState, path: &Path) -> anyhow::Result<bool> {
async fn file_exists(&self, path: &Path) -> anyhow::Result<bool> {
let query = self
.exists
.query_as::<(i32,)>()
Expand All @@ -459,7 +460,7 @@ impl DbFsQueries {
self.exists.sql(),
(path,)
);
let result = query.fetch_optional(&app_state.db.connection).await;
let result = query.fetch_optional(&self.pool).await;
log::debug!("DB File exists result: {result:?}");
result.map(|result| result.is_some()).with_context(|| {
format!(
Expand Down Expand Up @@ -488,7 +489,7 @@ async fn test_sql_file_read_utf8() -> anyhow::Result<()> {

let create_table_sql = DbFsQueries::get_create_table_sql(state.db.info.database_type);
let db = &state.db;
let conn = &db.connection;
let conn = db.connection.as_old_api().unwrap();
// Other tests share this database, so never drop a table their initialized state may use.
if conn
.execute("SELECT 1 FROM sqlpage_files WHERE 1 = 0")
Expand Down
4 changes: 2 additions & 2 deletions src/telemetry_metrics.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use crate::webserver::database::driver::DatabasePool;
use opentelemetry::global;
use opentelemetry::metrics::{Histogram, ObservableGauge};
use opentelemetry_semantic_conventions::attribute as otel;
use opentelemetry_semantic_conventions::metric as otel_metric;
use sqlx::any::AnyPool;

pub struct TelemetryMetrics {
pub http_request_duration: Histogram<f64>,
Expand Down Expand Up @@ -41,7 +41,7 @@ impl Default for TelemetryMetrics {

impl TelemetryMetrics {
#[must_use]
pub fn new(pool: &AnyPool, db_system_name: &'static str) -> Self {
pub fn new(pool: &DatabasePool, db_system_name: &'static str) -> Self {
let meter = global::meter("sqlpage");
let http_request_duration = meter
.f64_histogram(otel_metric::HTTP_SERVER_REQUEST_DURATION)
Expand Down
Loading
Loading