juanluispaz/ts-sql-query

Error on subSelect when picking just one value

Closed this issue · 3 comments

Given the following schema in SQL Server

CREATE TABLE categories (
  category_id bigint IDENTITY (1, 1) PRIMARY KEY,
  category_name varchar(255) NOT NULL
)

CREATE TABLE brands (
  brand_id bigint IDENTITY (1, 1) PRIMARY KEY,
  brand_name varchar(255) NOT NULL
)

CREATE TABLE products (
  product_id bigint IDENTITY (1, 1) PRIMARY KEY,
  product_name varchar(255) NOT NULL,
  model_year smallint NOT NULL,
  list_price DECIMAL(10, 2) NOT NULL,
  brand_id bigint NOT NULL,
  category_id bigint NOT NULL,
  FOREIGN KEY (category_id) REFERENCES categories(category_id) ON DELETE CASCADE ON UPDATE CASCADE,
  FOREIGN KEY (brand_id) REFERENCES brands(brand_id) ON DELETE CASCADE ON UPDATE CASCADE
)
Test data
SET IDENTITY_INSERT brands ON;

INSERT INTO brands(brand_id, brand_name) VALUES (1, 'Electra');

SET IDENTITY_INSERT brands OFF;

SET IDENTITY_INSERT categories ON;

INSERT INTO categories(category_id, category_name) VALUES (1, 'Children Bicycles');

SET IDENTITY_INSERT production.categories OFF;

SET IDENTITY_INSERT production.products ON;

INSERT INTO production.products(product_id, product_name, brand_id, category_id, model_year, list_price) VALUES (1, 'Electra Cruiser 1 (24-Inch) - 2016', 1, 1, 2016, 269.99);

SET IDENTITY_INSERT production.products OFF;

I want to select a product with brand and category as JSON columns, so I build the following query

const brand = new Brand();
const category = new Category();
const product = new Product().as('product');
const id = 1n;

const singleProductWithCategoryAndBrand = await connection
      .selectFrom(product)
      .select({
        id: product.id,
        name: product.name,
        modelYear: product.modelYear,
        listPrice: product.listPrice,
        category: connection
          .subSelectUsing(product)
          .from(category)
          .select({ id: category.id, name: category.name })
          .where(category.id.equals(product.categoryId))
          .customizeQuery({
            beforeQuery: connection.rawFragment`JSON_QUERY((`,
            afterQuery: connection.rawFragment`), '$[0]')`
          })
          .forUseAsInlineAggregatedArrayValue(),
        brand: connection
          .subSelectUsing(product)
          .from(brand)
          .select({ id: brand.id, name: brand.name })
          .where(brand.id.equals(product.brandId))
          .customizeQuery({
            beforeQuery: connection.rawFragment`JSON_QUERY((`,
            afterQuery: connection.rawFragment`), '$[0]')`
          })
          .forUseAsInlineAggregatedArrayValue(),
      })
      .where(product.id.equals(id))
      .executeSelectOne();

And produce the following query which is valid, I tested it directly in SQL Server

select
    product.product_id as id,
    product.product_name as name,
    product.model_year as modelYear,
    product.list_price as listPrice,
    (
        JSON_QUERY(
            (
                select
                    category_id as id,
                    category_name as name
                from
                    categories
                where
                    category_id = product.category_id
                for json path
            ),
            '$[0]'
        )
    ) as category,
    (
        JSON_QUERY(
            (
                select
                    brand_id as id,
                    brand_name as name
                from
                    brands
                where
                    brand_id = product.brand_id
                for json path
            ),
            '$[0]'
        )
    ) as brand
from
    production.products as product
where
    product.product_id = @0

But when I run the code I got an error

Error: Invalid JSON string coming from the database for the column `category`. An array were expected

Because forUseAsInlineAggregatedArrayValue expects an array but for my case it only produces one result in the subquery, so maybe you can add a method called forUseAsInlineValue for the cases when only a single result is expected

Hi,

It here any reason why are you doing a subquery instead of joins? Usually a join will be referred over a subquery.

If I use join I can rewrote your query as:

const brand = new Brand();
const category = new Category();
const product = new Product().as('product');
const id = 1n;

const singleProductWithCategoryAndBrand = await connection
      .selectFrom(product)
      .join(category).on(category.id.equals(product.categoryId))
      .join(brand).on(brand.id.equals(product.brandId))
      .select({
        id: product.id,
        name: product.name,
        modelYear: product.modelYear,
        listPrice: product.listPrice,
        category: { 
              id: category.id, 
              name: category.name 
        },
        brand: { 
              id: brand.id, 
              name: brand.name 
        }
      })
      .where(product.id.equals(id))
      .executeSelectOne();

This will do what you want in a more readable way. Can you explain yourself a little bit.

Note: In case your relation were optional, I will use a left join.

Some documentation: Complex projections, Inner objects and joins, Inner objects and left joins

Let me know your feedback.

I'm currently using mssql to connect and query the DB, so I was trying to replicate the queries that I already have.

The one where I use FOR JSON AUTO and JSON_QUERY is to get a resultset ready to send as a JSON object, so with mssql if I use joins then I'll have to transform the columns of the resultset into a JSON object before send it.

But, be aware, forUseAsInlineAggregatedArrayValue will not return the JSON string representation; instead, a real object will be projected in JavaScript (yes, it uses for json path in SQL Server to perform it, but you never will get the JSON string).

Sending the straightforward JSON representation of the output will be very limiting for your backend, and if that is the case, why do not just send the whole result of the query as JSON serialized in the DB?

I'm not sure why you want to hide those results to your backend but still keep some parts visible.

If you still need a JSON string representation of your query, you can use a Sql Fragment to change the output (the result of the fragment have a different type than the subquery type), something like this:

const brand = new Brand();
const category = new Category();
const product = new Product().as('product');
const id = 1n;

const singleProductWithCategoryAndBrand = await connection
      .selectFrom(product)
      .select({
        id: product.id,
        name: product.name,
        modelYear: product.modelYear,
        listPrice: product.listPrice,
        category: connection.fragmentWithType('string', 'required').sql`JSON_QUERY((${
          connection
          .subSelectUsing(product)
          .from(category)
          .select({ id: category.id, name: category.name })
          .where(category.id.equals(product.categoryId))
          .forUseAsInlineAggregatedArrayValue(),
        }), '$[0]')`
        brand: connection.fragmentWithType('string', 'required').sql`JSON_QUERY((${
          connection.
          .subSelectUsing(product)
          .from(brand)
          .select({ id: brand.id, name: brand.name })
          .where(brand.id.equals(product.brandId))
          .forUseAsInlineAggregatedArrayValue(),
        }, '$[0]')`
      })
      .where(product.id.equals(id))
      .executeSelectOne();

I've released ts-sql-query 1.56.0, fixing an issue that will not allow you to use the subquery in a sql fragment.

Let me know what you think.