Skip to content

Preserve type when writing to ps_crud #117

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 6, 2025
Merged
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
10 changes: 5 additions & 5 deletions Cargo.lock

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

3 changes: 1 addition & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ inherits = "release"
inherits = "wasm"

[workspace.package]
version = "0.4.3"
version = "0.4.4"
edition = "2024"
authors = ["JourneyApps"]
keywords = ["sqlite", "powersync"]
Expand All @@ -38,4 +38,3 @@ repository = "https://github.com/powersync-ja/powersync-sqlite-core"

[workspace.dependencies]
sqlite_nostd = { path="./sqlite-rs-embedded/sqlite_nostd" }

2 changes: 1 addition & 1 deletion android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ plugins {
}

group = "com.powersync"
version = "0.4.3"
version = "0.4.4"
description = "PowerSync Core SQLite Extension"

val localRepo = uri("build/repository/")
Expand Down
2 changes: 1 addition & 1 deletion android/src/prefab/prefab.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
"name": "powersync_sqlite_core",
"schema_version": 2,
"dependencies": [],
"version": "0.4.3"
"version": "0.4.4"
}
2 changes: 2 additions & 0 deletions crates/core/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ pub const FULL_GIT_HASH: &'static str = env!("GIT_HASH");
// we're testing with the minimum version we claim to support.
pub const MIN_SQLITE_VERSION_NUMBER: c_int = 3044000;

pub const SUBTYPE_JSON: u32 = 'J' as u32;

pub fn short_git_hash() -> &'static str {
&FULL_GIT_HASH[..8]
}
10 changes: 6 additions & 4 deletions crates/core/src/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,20 @@ use sqlite::ResultCode;
use sqlite_nostd as sqlite;
use sqlite_nostd::{Connection, Context, Value};

use serde_json as json;

use crate::constants::SUBTYPE_JSON;
use crate::create_sqlite_text_fn;
use crate::error::PowerSyncError;
use serde_json as json;
use sqlite_nostd::bindings::SQLITE_RESULT_SUBTYPE;

fn powersync_diff_impl(
_ctx: *mut sqlite::context,
ctx: *mut sqlite::context,
args: &[*mut sqlite::value],
) -> Result<String, PowerSyncError> {
let data_old = args[0].text();
let data_new = args[1].text();

ctx.result_subtype(SUBTYPE_JSON);
diff_objects(data_old, data_new)
}

Expand Down Expand Up @@ -66,7 +68,7 @@ pub fn register(db: *mut sqlite::sqlite3) -> Result<(), ResultCode> {
db.create_function_v2(
"powersync_diff",
2,
sqlite::UTF8 | sqlite::DETERMINISTIC,
sqlite::UTF8 | sqlite::DETERMINISTIC | SQLITE_RESULT_SUBTYPE,
None,
Some(powersync_diff),
None,
Expand Down
35 changes: 31 additions & 4 deletions crates/core/src/json_merge.rs → crates/core/src/json_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,34 @@ extern crate alloc;
use alloc::string::{String, ToString};
use core::ffi::c_int;

use crate::constants::SUBTYPE_JSON;
use crate::create_sqlite_text_fn;
use crate::error::PowerSyncError;
use sqlite::ResultCode;
use sqlite_nostd as sqlite;
use sqlite_nostd::bindings::{SQLITE_RESULT_SUBTYPE, SQLITE_SUBTYPE};
use sqlite_nostd::{Connection, Context, Value};

use crate::create_sqlite_text_fn;
use crate::error::PowerSyncError;
extern "C" fn powersync_strip_subtype(
ctx: *mut sqlite::context,
argc: c_int,
argv: *mut *mut sqlite::value,
) {
if argc != 1 {
return;
}

let arg = unsafe { *argv };
ctx.result_value(arg);
ctx.result_subtype(0);
}

/// Given any number of JSON TEXT arguments, merge them into a single JSON object.
///
/// This assumes each argument is a valid JSON object, with no duplicate keys.
/// No JSON parsing or validation is performed - this performs simple string concatenation.
fn powersync_json_merge_impl(
_ctx: *mut sqlite::context,
ctx: *mut sqlite::context,
args: &[*mut sqlite::value],
) -> Result<String, PowerSyncError> {
if args.is_empty() {
Expand All @@ -42,6 +57,7 @@ fn powersync_json_merge_impl(

// Close the outer brace
result.push('}');
ctx.result_subtype(SUBTYPE_JSON);
Ok(result)
}

Expand All @@ -55,13 +71,24 @@ pub fn register(db: *mut sqlite::sqlite3) -> Result<(), ResultCode> {
db.create_function_v2(
"powersync_json_merge",
-1,
sqlite::UTF8 | sqlite::DETERMINISTIC,
sqlite::UTF8 | sqlite::DETERMINISTIC | SQLITE_RESULT_SUBTYPE,
None,
Some(powersync_json_merge),
None,
None,
None,
)?;

db.create_function_v2(
"powersync_strip_subtype",
1,
sqlite::UTF8 | sqlite::DETERMINISTIC | SQLITE_SUBTYPE | SQLITE_RESULT_SUBTYPE,
None,
Some(powersync_strip_subtype),
None,
None,
None,
)?;

Ok(())
}
4 changes: 2 additions & 2 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ mod diff;
mod error;
mod ext;
mod fix_data;
mod json_merge;
mod json_util;
mod kv;
mod macros;
mod migrations;
Expand Down Expand Up @@ -73,7 +73,7 @@ fn init_extension(db: *mut sqlite::sqlite3) -> Result<(), PowerSyncError> {
crate::uuid::register(db)?;
crate::diff::register(db)?;
crate::fix_data::register(db)?;
crate::json_merge::register(db)?;
crate::json_util::register(db)?;
crate::view_admin::register(db)?;
crate::checkpoint::register(db)?;
crate::kv::register(db)?;
Expand Down
17 changes: 9 additions & 8 deletions crates/core/src/sync/interface.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
use core::cell::RefCell;
use core::ffi::{c_int, c_void};

use super::streaming_sync::SyncClient;
use super::sync_status::DownloadSyncStatus;
use crate::constants::SUBTYPE_JSON;
use crate::error::PowerSyncError;
use crate::schema::Schema;
use crate::state::DatabaseState;
use alloc::borrow::Cow;
use alloc::boxed::Box;
use alloc::rc::Rc;
use alloc::sync::Arc;
use alloc::{string::String, vec::Vec};
use serde::{Deserialize, Serialize};
use sqlite::{ResultCode, Value};
use sqlite_nostd::bindings::SQLITE_RESULT_SUBTYPE;
use sqlite_nostd::{self as sqlite, ColumnType};
use sqlite_nostd::{Connection, Context};

use crate::error::PowerSyncError;
use crate::schema::Schema;
use crate::state::DatabaseState;

use super::streaming_sync::SyncClient;
use super::sync_status::DownloadSyncStatus;

/// Payload provided by SDKs when requesting a sync iteration.
#[derive(Default, Deserialize)]
pub struct StartSyncStream {
Expand Down Expand Up @@ -186,6 +186,7 @@ pub fn register(db: *mut sqlite::sqlite3, state: Arc<DatabaseState>) -> Result<(
let formatted =
serde_json::to_string(&instructions).map_err(PowerSyncError::internal)?;
ctx.result_text_transient(&formatted);
ctx.result_subtype(SUBTYPE_JSON);

Ok(())
})();
Expand All @@ -206,7 +207,7 @@ pub fn register(db: *mut sqlite::sqlite3, state: Arc<DatabaseState>) -> Result<(
db.create_function_v2(
"powersync_control",
2,
sqlite::UTF8 | sqlite::DIRECTONLY,
sqlite::UTF8 | sqlite::DIRECTONLY | SQLITE_RESULT_SUBTYPE,
Some(Box::into_raw(controller).cast()),
Some(control),
None,
Expand Down
29 changes: 10 additions & 19 deletions crates/core/src/views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,26 +415,17 @@ fn json_object_fragment<'a>(
let mut column_names_quoted: Vec<String> = alloc::vec![];
while let Some(column) = columns.next() {
let name = &*column.name;
let quoted = match &*column.type_name {
// We really want the individual columns here to appear as they show up in the database.
// For text columns however, it's possible that e.g. NEW.column was created by a JSON
// function, meaning that it has a JSON subtype active - causing the json_object() call
// we're about to emit to include it as a subobject instead of a string.
"TEXT" | "text" => format!(
"{:}, concat({:}.{:})",
QuotedString(name),
prefix,
quote_identifier(name)
),
_ => format!(
"{:}, {:}.{:}",
QuotedString(name),
prefix,
quote_identifier(name)
),
};

column_names_quoted.push(quoted);
// We really want the individual columns here to appear as they show up in the database.
// For text columns however, it's possible that e.g. NEW.column was created by a JSON
// function, meaning that it has a JSON subtype active - causing the json_object() call
// we're about to emit to include it as a subobject instead of a string.
column_names_quoted.push(format!(
"{:}, powersync_strip_subtype({:}.{:})",
QuotedString(name),
prefix,
quote_identifier(name)
));
}

// SQLITE_MAX_COLUMN - 1 (because of the id column)
Expand Down
41 changes: 37 additions & 4 deletions dart/test/crud_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,7 @@ void main() {
expect(db.select('SELECT * FROM ps_crud'), hasLength(1));
});

test('json values are included as text', () {
test('preserves values in text column', () {
db
..execute('select powersync_replace_schema(?)', [
json.encode({
Expand All @@ -675,11 +675,44 @@ void main() {
}
]
})
])
..execute('INSERT INTO items (id, col) VALUES (uuid(), json_object())');
]);

db.execute('INSERT INTO items (id, col) VALUES (uuid(), json_object())');
final [insert] = db.select('SELECT data FROM ps_crud');
expect(json.decode(insert['data']), containsPair('data', {'col': '{}'}));
db.execute('DELETE FROM ps_crud');

db.execute('UPDATE items SET col = NULL;');
final [update] = db.select('SELECT data FROM ps_crud');
expect(json.decode(update['data']), containsPair('data', {'col': '{}'}));
expect(json.decode(update['data']), containsPair('data', {'col': null}));
db.execute('DELETE FROM ps_crud');
});

test('preserves mismatched type', () {
db
..execute('select powersync_replace_schema(?)', [
json.encode({
'tables': [
{
'name': 'items',
'columns': [
{'name': 'col', 'type': 'int'}
],
}
]
})
])
..execute('insert into items (id, col) values (uuid(), json_object())')
..execute('insert into items (id, col) values (uuid(), null)')
..execute('insert into items (id, col) values (uuid(), ?)',
['not an integer']);

final data = db.select('SELECT data FROM ps_crud');
expect(data.map((row) => jsonDecode(row['data'])), [
containsPair('data', {'col': '{}'}),
containsPair('data', {}),
containsPair('data', {'col': 'not an integer'}),
]);
});
});
}
Loading
Loading