Skip to content
3 changes: 3 additions & 0 deletions .github/workflows/rust.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ jobs:

- name: Run tests
run: cargo test -- --test-threads=1
env:
MYSQL_VERSION: ${{ matrix.db.mysql }}
PG_VERSION: ${{ matrix.db.postgres }}

lint:
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ version: '3.1'
services:

postgres:
platform: linux/amd64
image: postgres:${PG_VERSION:-13}
restart: always
environment:
Expand All @@ -13,6 +14,7 @@ services:
- 54321:5432

mysql:
platform: linux/amd64
image: mysql:${MYSQL_VERSION:-8}
restart: always
volumes:
Expand Down
51 changes: 51 additions & 0 deletions playpen/db/mysql_migration.sql
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,54 @@ CREATE TABLE random (
-- JSON types
json1 JSON
);

-- JSON Test Data Table
-- This table contains various JSON structures for testing JSON operators and functions
CREATE TABLE json_test_data (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
data JSON NOT NULL,
metadata JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO json_test_data (name, data, metadata) VALUES
-- Simple object
('user_profile',
'{"userId": 1, "username": "john_doe", "email": "john@example.com", "age": 30, "active": true}',
'{"source": "api", "version": "1.0"}'),

-- Nested object with address
('user_with_address',
'{"userId": 2, "username": "jane_smith", "email": "jane@example.com", "address": {"street": "123 Main St", "city": "Springfield", "state": "IL", "zipCode": "62701", "country": "USA"}}',
'{"source": "import", "version": "1.0"}'),

-- Array of items
('shopping_cart',
'{"cartId": 101, "items": [{"productId": 1, "name": "Laptop", "quantity": 1, "price": 999.99}, {"productId": 2, "name": "Mouse", "quantity": 2, "price": 25.50}], "totalPrice": 1050.99}',
'{"source": "web", "version": "2.0"}'),

-- Array of strings
('tags',
'{"postId": 42, "title": "MySQL JSON Functions", "tags": ["database", "mysql", "json", "tutorial"], "published": true}',
'{"source": "cms", "version": "1.0"}'),

-- Nested arrays and objects
('game_stats',
'{"playerId": 123, "stats": {"level": 50, "experience": 125000, "inventory": [{"slot": 1, "item": "Sword of Fire", "rarity": "legendary"}, {"slot": 2, "item": "Shield of Light", "rarity": "epic"}], "achievements": ["First Kill", "Level 50", "Legendary Item"]}}',
'{"source": "game_server", "version": "3.0"}'),

-- Deep nesting
('nested_config',
'{"app": {"name": "MyApp", "version": "1.0.0", "settings": {"database": {"host": "localhost", "port": 3306, "credentials": {"username": "admin", "encrypted": true}}, "features": {"darkMode": true, "notifications": {"email": true, "push": false}}}}}',
'{"source": "config", "version": "1.0"}'),

-- Array of objects with nulls
('product_reviews',
'{"productId": 456, "reviews": [{"reviewId": 1, "rating": 5, "comment": "Excellent product!", "reviewer": "Alice"}, {"reviewId": 2, "rating": 4, "comment": null, "reviewer": "Bob"}, {"reviewId": 3, "rating": 3, "comment": "Average", "reviewer": null}]}',
'{"source": "reviews", "version": "1.0"}'),

-- Mixed types
('analytics',
'{"date": "2024-01-15", "metrics": {"visitors": 1500, "pageViews": 4500, "bounceRate": 0.35, "sources": {"organic": 850, "direct": 400, "referral": 250}}}',
'{"source": "analytics", "version": "1.0"}');
51 changes: 51 additions & 0 deletions playpen/db/mysql_migration_5_6.sql
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,54 @@ CREATE TABLE random (

json1 TEXT
);

-- JSON Test Data Table
-- This table contains various JSON structures for testing JSON operators and functions
CREATE TABLE json_test_data (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
data TEXT NOT NULL,
metadata TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO json_test_data (name, data, metadata) VALUES
-- Simple object
('user_profile',
'{"userId": 1, "username": "john_doe", "email": "john@example.com", "age": 30, "active": true}',
'{"source": "api", "version": "1.0"}'),

-- Nested object with address
('user_with_address',
'{"userId": 2, "username": "jane_smith", "email": "jane@example.com", "address": {"street": "123 Main St", "city": "Springfield", "state": "IL", "zipCode": "62701", "country": "USA"}}',
'{"source": "import", "version": "1.0"}'),

-- Array of items
('shopping_cart',
'{"cartId": 101, "items": [{"productId": 1, "name": "Laptop", "quantity": 1, "price": 999.99}, {"productId": 2, "name": "Mouse", "quantity": 2, "price": 25.50}], "totalPrice": 1050.99}',
'{"source": "web", "version": "2.0"}'),

-- Array of strings
('tags',
'{"postId": 42, "title": "MySQL JSON Functions", "tags": ["database", "mysql", "json", "tutorial"], "published": true}',
'{"source": "cms", "version": "1.0"}'),

-- Nested arrays and objects
('game_stats',
'{"playerId": 123, "stats": {"level": 50, "experience": 125000, "inventory": [{"slot": 1, "item": "Sword of Fire", "rarity": "legendary"}, {"slot": 2, "item": "Shield of Light", "rarity": "epic"}], "achievements": ["First Kill", "Level 50", "Legendary Item"]}}',
'{"source": "game_server", "version": "3.0"}'),

-- Deep nesting
('nested_config',
'{"app": {"name": "MyApp", "version": "1.0.0", "settings": {"database": {"host": "localhost", "port": 3306, "credentials": {"username": "admin", "encrypted": true}}, "features": {"darkMode": true, "notifications": {"email": true, "push": false}}}}}',
'{"source": "config", "version": "1.0"}'),

-- Array of objects with nulls
('product_reviews',
'{"productId": 456, "reviews": [{"reviewId": 1, "rating": 5, "comment": "Excellent product!", "reviewer": "Alice"}, {"reviewId": 2, "rating": 4, "comment": null, "reviewer": "Bob"}, {"reviewId": 3, "rating": 3, "comment": "Average", "reviewer": null}]}',
'{"source": "reviews", "version": "1.0"}'),

-- Mixed types
('analytics',
'{"date": "2024-01-15", "metrics": {"visitors": 1500, "pageViews": 4500, "bounceRate": 0.35, "sources": {"organic": 850, "direct": 400, "referral": 250}}}',
'{"source": "analytics", "version": "1.0"}');
51 changes: 51 additions & 0 deletions playpen/db/postgres_migration.sql
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,54 @@ INSERT INTO classes (name, specialization) VALUES
('druid', '{"role": "hybrid", "weapon": "staff", "abilities": ["shapeshift", "moonfire", "regrowth"]}'),
('mage', '{"role": "ranged", "weapon": "wand", "abilities": ["fireball", "frostbolt", "arcane blast"]}'),
('warlock', '{"role": "ranged", "weapon": "dagger", "abilities": ["summon demon", "shadowbolt", "curse of agony"]}');

-- JSON Test Data Table
-- This table contains various JSON structures for testing JSON operators and functions
CREATE TABLE json_test_data (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
data JSONB NOT NULL,
metadata JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO json_test_data (name, data, metadata) VALUES
-- Simple object
('user_profile',
'{"userId": 1, "username": "john_doe", "email": "john@example.com", "age": 30, "active": true}',
'{"source": "api", "version": "1.0"}'),

-- Nested object with address
('user_with_address',
'{"userId": 2, "username": "jane_smith", "email": "jane@example.com", "address": {"street": "123 Main St", "city": "Springfield", "state": "IL", "zipCode": "62701", "country": "USA"}}',
'{"source": "import", "version": "1.0"}'),

-- Array of items
('shopping_cart',
'{"cartId": 101, "items": [{"productId": 1, "name": "Laptop", "quantity": 1, "price": 999.99}, {"productId": 2, "name": "Mouse", "quantity": 2, "price": 25.50}], "totalPrice": 1050.99}',
'{"source": "web", "version": "2.0"}'),

-- Array of strings
('tags',
'{"postId": 42, "title": "PostgreSQL JSON Functions", "tags": ["database", "postgresql", "json", "tutorial"], "published": true}',
'{"source": "cms", "version": "1.0"}'),

-- Nested arrays and objects
('game_stats',
'{"playerId": 123, "stats": {"level": 50, "experience": 125000, "inventory": [{"slot": 1, "item": "Sword of Fire", "rarity": "legendary"}, {"slot": 2, "item": "Shield of Light", "rarity": "epic"}], "achievements": ["First Kill", "Level 50", "Legendary Item"]}}',
'{"source": "game_server", "version": "3.0"}'),

-- Deep nesting
('nested_config',
'{"app": {"name": "MyApp", "version": "1.0.0", "settings": {"database": {"host": "localhost", "port": 5432, "credentials": {"username": "admin", "encrypted": true}}, "features": {"darkMode": true, "notifications": {"email": true, "push": false}}}}}',
'{"source": "config", "version": "1.0"}'),

-- Array of objects with nulls
('product_reviews',
'{"productId": 456, "reviews": [{"reviewId": 1, "rating": 5, "comment": "Excellent product!", "reviewer": "Alice"}, {"reviewId": 2, "rating": 4, "comment": null, "reviewer": "Bob"}, {"reviewId": 3, "rating": 3, "comment": "Average", "reviewer": null}]}',
'{"source": "reviews", "version": "1.0"}'),

-- Mixed types
('analytics',
'{"date": "2024-01-15", "metrics": {"visitors": 1500, "pageViews": 4500, "bounceRate": 0.35, "sources": {"organic": 850, "direct": 400, "referral": 250}}}',
'{"source": "analytics", "version": "1.0"}');
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
use crate::common::lazy::DB_SCHEMA;
use crate::core::connection::DBConn;
use crate::ts_generator::errors::TsGeneratorError;
use crate::ts_generator::sql_parser::expressions::function_handlers::FunctionHandlersContext;
use crate::ts_generator::sql_parser::expressions::translate_data_type::translate_value;
use crate::ts_generator::sql_parser::expressions::translate_table_with_joins::translate_table_from_expr;
use crate::ts_generator::sql_parser::quoted_strings::DisplayIndent;
use crate::ts_generator::types::ts_query::TsFieldType;
use sqlparser::ast::{Expr, FunctionArg, FunctionArgExpr, TableWithJoins, Value};

/// Extract key name from a function argument (should be a string literal)
fn extract_key_name(arg: &FunctionArg) -> Option<String> {
match arg {
FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Value(val))) => match &val.value {
Value::SingleQuotedString(s) | Value::DoubleQuotedString(s) => Some(s.clone()),
_ => None,
},
_ => None,
}
}

/// Extract expression from a function argument
fn extract_expr_from_arg(arg: &FunctionArg) -> Option<&Expr> {
match arg {
FunctionArg::Unnamed(FunctionArgExpr::Expr(expr)) => Some(expr),
FunctionArg::Named {
arg: FunctionArgExpr::Expr(expr),
..
} => Some(expr),
_ => None,
}
}

/// Infer the TypeScript type from an SQL expression
pub async fn infer_type_from_expr(
expr: &Expr,
single_table_name: &Option<&str>,
table_with_joins: &Option<Vec<TableWithJoins>>,
db_conn: &DBConn,
) -> Option<(TsFieldType, bool)> {
match expr {
Expr::Identifier(ident) => {
let column_name = DisplayIndent(ident).to_string();
if let Some(table_name) = single_table_name {
let table_details = DB_SCHEMA.lock().await.fetch_table(&vec![table_name], db_conn).await;

if let Some(table_details) = table_details {
if let Some(field) = table_details.get(&column_name) {
Some((field.field_type.to_owned(), field.is_nullable))
} else {
Some((TsFieldType::Any, false))
}
} else {
Some((TsFieldType::Any, false))
}
} else {
Some((TsFieldType::Any, false))
}
}
Expr::CompoundIdentifier(idents) if idents.len() == 2 => {
let column_name = DisplayIndent(&idents[1]).to_string();
if let Ok(table_name) = translate_table_from_expr(table_with_joins, expr) {
let table_details = DB_SCHEMA
.lock()
.await
.fetch_table(&vec![table_name.as_str()], db_conn)
.await;

if let Some(table_details) = table_details {
if let Some(field) = table_details.get(&column_name) {
Some((field.field_type.to_owned(), field.is_nullable))
} else {
Some((TsFieldType::Any, false))
}
} else {
Some((TsFieldType::Any, false))
}
} else {
Some((TsFieldType::Any, false))
}
}
Expr::Value(val) => {
if let Some(ts_field_type) = translate_value(&val.value) {
Some((ts_field_type, false))
} else {
Some((TsFieldType::Any, false))
}
}
_ => Some((TsFieldType::Any, false)),
}
}

/// Process key-value pairs from JSON build object arguments
pub async fn process_json_build_object_args(
args: &[FunctionArg],
single_table_name: &Option<&str>,
table_with_joins: &Option<Vec<TableWithJoins>>,
db_conn: &DBConn,
) -> Option<Vec<(String, TsFieldType, bool)>> {
if !args.len().is_multiple_of(2) {
// Invalid number of arguments
return None;
}

let mut object_fields = vec![];

// Process key-value pairs
for i in (0..args.len()).step_by(2) {
let key_arg = &args[i];
let value_arg = &args[i + 1];

// Extract key name
let key_name = extract_key_name(key_arg)?;

// Extract value expression
let value_expr = extract_expr_from_arg(value_arg)?;

// Infer value type
let (value_type, is_nullable) =
infer_type_from_expr(value_expr, single_table_name, table_with_joins, db_conn).await?;

object_fields.push((key_name, value_type, is_nullable));
}

Some(object_fields)
}

/// Handle JSON build functions (jsonb_build_object, json_build_object, etc.)
pub async fn handle_json_build_function(
function_name: &str,
args: &[FunctionArg],
ctx: &mut FunctionHandlersContext<'_>,
) -> Result<(), TsGeneratorError> {
let expr_log = ctx.expr_for_logging.unwrap_or("");

// Handle jsonb_build_object / json_build_object / json_object (MySQL)
let func_upper = function_name.to_uppercase();
if func_upper == "JSONB_BUILD_OBJECT" || func_upper == "JSON_BUILD_OBJECT" || func_upper == "JSON_OBJECT" {
if let Some(object_fields) =
process_json_build_object_args(args, ctx.single_table_name, ctx.table_with_joins, ctx.db_conn).await
{
let object_type = TsFieldType::StructuredObject(object_fields);
return ctx
.ts_query
.insert_result(Some(ctx.alias), &[object_type], ctx.is_selection, false, expr_log);
}
}

// For other build functions or on failure, return Any
ctx
.ts_query
.insert_result(Some(ctx.alias), &[TsFieldType::Any], ctx.is_selection, false, expr_log)
}

/// Handle JSON aggregation functions (jsonb_agg, json_agg, etc.)
pub async fn handle_json_agg_function(
args: &[FunctionArg],
ctx: &mut FunctionHandlersContext<'_>,
) -> Result<(), TsGeneratorError> {
use super::super::functions::is_json_build_function;
use sqlparser::ast::FunctionArguments;

let expr_log = ctx.expr_for_logging.unwrap_or("");

// jsonb_agg typically takes a single expression
if args.len() != 1 {
return ctx
.ts_query
.insert_result(Some(ctx.alias), &[TsFieldType::Any], ctx.is_selection, false, expr_log);
}

let arg_expr = extract_expr_from_arg(&args[0]);

// Check if the argument is a jsonb_build_object function
if let Some(Expr::Function(inner_func)) = arg_expr {
let inner_func_name = inner_func.name.to_string();
if is_json_build_function(inner_func_name.as_str()) {
// Extract arguments from the inner function
let inner_args = match &inner_func.args {
FunctionArguments::List(arg_list) => &arg_list.args,
_ => {
return ctx
.ts_query
.insert_result(Some(ctx.alias), &[TsFieldType::Any], ctx.is_selection, false, expr_log);
}
};

// Process the inner jsonb_build_object
if let Some(object_fields) =
process_json_build_object_args(inner_args, ctx.single_table_name, ctx.table_with_joins, ctx.db_conn).await
{
let object_type = TsFieldType::StructuredObject(object_fields);
let array_type = TsFieldType::Array(Box::new(object_type));
return ctx
.ts_query
.insert_result(Some(ctx.alias), &[array_type], ctx.is_selection, false, expr_log);
}
}
}

// If we can't infer the type, return Any
ctx
.ts_query
.insert_result(Some(ctx.alias), &[TsFieldType::Any], ctx.is_selection, false, expr_log)
}
Loading
Loading