Skip to content
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

parseJsonLogic support for array operations like "some" #635

Open
jakeboone02 opened this issue Jan 16, 2024 Discussed in #634 · 9 comments
Open

parseJsonLogic support for array operations like "some" #635

jakeboone02 opened this issue Jan 16, 2024 Discussed in #634 · 9 comments

Comments

@jakeboone02
Copy link
Member

Discussed in #634

Originally posted by nicopadu January 16, 2024
Given the structure of our source data, we'd need to deal with Array operators from jsonLogic.
Specifically with "some" operator, combined with "in".
https://jsonlogic.com/operations.html#all-none-and-some

Checking in the code, there is some commented code related to unused operations in this file
https://github.com/react-querybuilder/react-querybuilder/blob/main/packages/react-querybuilder/src/utils/parseJsonLogic/utils.ts

Are they not supported due to any known issue? Or just that this requirement was not relevant or present at the moment of jsonLogic support implementation?

I guess that supporting these kind of operators would require also extra settings in Fields properties?
Something like "group" type of fields as here https://github.com/ukrbublik/react-awesome-query-builder/blob/master/CONFIG.adoc#configfields

@jakeboone02
Copy link
Member Author

@nicopadu I've been looking at this off and on for a couple days now, and I'm not sure I understand exactly how to translate the "all"/"some"/"none" operations from JsonLogic to RQB's format. RQB doesn't really have the same nested field concept as RAQB, at least not natively.

Can you give me an example of a JsonLogic rule with those operations and the RQB query you'd expect from parseJsonLogic?

@nicopadu
Copy link

Given this data:

{ "MyRecordObj": [
  { "foo": 123, "bar": "abc", "arr": [ "y", "z" ] },
  { "foo": 456, "bar": "def", "arr": [ "x", "w" ] },
  { "foo": 789, "bar": "jkl", "arr": [ "v", "w" ] }
]}

These rules return TRUE

{"some":[
  {"var":"MyRecordObj"},
  {"and":[
    {"==":[{"var":"foo"},123]},
	{"==":[{"var":"bar"},"abc"]}
  ]}
]}
{"some":[
  {"var":"MyRecordObj"},
  {"and":[
    {"==":[{"var":"foo"},123]},
	{"some":[
	  {"var":"arr"},
	  {"in":[ {"var":""}, ["a", "z"] ]}
	]} 
  ]}
]}

These rules return FALSE

{"some":[
  {"var":"MyRecordObj"},
  {"and":[
    {"==":[{"var":"foo"},123]},
	{"==":[{"var":"bar"},"def"]}
  ]}
]}
{"some":[
  {"var":"MyRecordObj"},
  {"and":[
    {"==":[{"var":"foo"},789]},
	{"some":[
	  {"var":"arr"},
	  {"in":[ {"var":""}, ["a", "z"] ]}
	]} 
  ]}
]}

I guess that the key point is the ability to support nested fields like in RAQB as you mention.
I'd assume that "some" operator followed by "and" with any nested field would cover large number of cases.

@jakeboone02
Copy link
Member Author

I think I understand how the JsonLogic rules themselves work. I guess what I'm asking is this: since RQB doesn't natively support a structure for nested fields, what would you want a query to look like if it did?

I don't want to match RAQB's format exactly because their base query structure is more complicated. I'd prefer a more minimalistic approach.

@fridaystreet
Copy link

fridaystreet commented Mar 8, 2024

would be very interested in this, we also use some for array to array comparisons.

The scenario being a user wants to do an 'in' on an array field but select multiple values to check if they are in the array.

Would it possibly be as simple as have a 'some' operator with a multiselect value?

eg given data
{ myArray: [1,2,3] }

Rule would be:

Field = myArray
op=some
value=[1,2]

which would convert to:

{"some":[
  {"var":"myArray"},
  {"in":[{"var":""}, <insert value array here>]}
]}

If you wanted to handle subobjects, maybe something like

{ myArray: [{a:1},{a:2},{a:3}] }

Rule would be:

Field = myArray.a
op=some
value=[1,2]

which would convert to:

{"some":[
  {"var":"myArray"},
  {"in":[{"var":"a"}, <insert value array here>]}
]}

probably just need a bit of funkiness to separate the array object key from the array, but that wouldn't be that difficult. Just check the full path myArray.a, if that's not an array, iterate up through the parents until you find the first array. Separate the path at that point and you end up with arrayPath=myArray and arrayKey=a

Just a thought

Edit: after thought, in fact do you even need the 'some' operator? if the field is an array and the value is a multiselect and the operator is 'in' that just defaults to a some rule outcome?

I mean the some operator might be good idea to imply the same thing more explcitally, also using the operator as some,all,none would then let you determine how you want the array to array comparions to be handled

@fridaystreet
Copy link

fridaystreet commented Mar 8, 2024

Apologies, I keep forgetting, this library isn't connected to the data, it needs to define the rules in isolation. So my example above about detecting the array and key for subobjects isn't going to be much use here.

I'm not a fan of anything too bespoke, but I guess you could still use some sort of naming convention in the field name if the operator is some,all,none

This isn't great, but as an example

field=some.path.to.myArray[path,to,the,key]
operator=some
value=[1,2,3]

or if not precious about adding extra fields to the the field/rule definition then just have an extra field for key, but you'd need the extra input filed in the UI

field=some.path.to.myArray
key=path.to.the.key
operator=some
value=[1,2,3]

@jakeboone02
Copy link
Member Author

@fridaystreet I think what you're talking about is a sort of like a "field+subfield" um...field. So the UI would display two drop down lists, one for the main field and one for the subfield. I would recommend the "naming convention" method you alluded to.

If you wanted to go with dot-separated, it would be something like:

const fields: Field[] = [
  { name: "some.field", label: "Some.Field" },
  { name: "some.otherField", label: "Some.Other Field" }
];

Then in your custom fieldSelector component, you split each name in the props.options array to get the two values for your selects, split each label to get the labels, and split the value prop to know which field/subfield is selected. When you call props.handleOnChange, join the values of the selects back together so the field attribute of the rule matches a name in the fields array.

Hope that helps!

@fridaystreet
Copy link

@jakeboone02 yeah the extra UI stuff ends up being painful I think, but sometimes bespoke naming conventions get a bit hariy and confusing so was just offering up the two approaches. I agree though, building in some sort of naming convention would be my preferred approach.

Maybe I'm not following your use of some in the above example. I would have thought some can just be an operator?

I think the problem is handling the array object key to use. if some, all , none is just another operator, it implies both sides of the rule are arrays that need an 'in' comparison, so you don't need to specify the in(contains) operator as well. It will handle non object array items as is, the problem comes trying to then handle comparison of arrays of objects

@fridaystreet
Copy link

This will handle non object arrays.

export const jsonlogicRuleProcessor = (rule, options) => {
  
  if (['some', 'all', 'none'].includes(rule.operator)) {
    return { 
     [rule.operator]: [
        { var: rule.field },
        { in: [{var: ""}, { var: rule.value }] }
      ]
    }
  }

  return defaultRuleProcessorJsonLogic(rule, options);
}

Will keep thinking about the object array one, but for us our field lists are very dynamic allowing users to pick fields/paths inside 100's of different complex event structures. So I think what we probably would need is to preprocess our schemas more to pull out the subfields, which at this stage I think it's easier to just work out if it should be a some or just in once you have both the rules and the data.

ie as I mentioned, check the path the users selected as the rule, if it's an array great just do the some, if it's not work out where the array is in the parent tree and then extract the object key from that.

@fridaystreet
Copy link

fridaystreet commented Mar 9, 2024

Not sure if this will work just dropped in as is. I've just lifted it out of our code into a single file for brevity. Also haven't fully tested all edge cases for operators, but hopefully it will serve as a good starting point for anyone tryting to do this

So basically this will take a json schema, turn it into an array of fields for the builder. It will set the field datatypes if possible (can be expanded through getEditorType), allow input value or value field selection. Add comparators to each field so that only fields with matching types can be selected in the value.

Mainly it will add arrayPath and arrayKey to the field definition. This keeps track of the parent array path and and allows any depth of array item object. As the whole field definition isn't passed to the jsonLogicProcessor, I've had to jimmy this up a bit by passing a getField function through the rule in the context in AddRuleAction, which is passed to the processor and it can retrieve the full rule definition with the arrayKey arrayPath fields.

The output will be a fully dotted list of field names allowing any field in the shema to be selected.

for arrays it will add both the array field and all the fields of the array items

eg given

{
  path: {
    to: {
      array: [
        {
          some: {
            key: {
              in: {
                array: 'myvalue'
              }
            }
          }
        }
      ]
    }
  }
}

you will get 2 fields:

path.to.array
path.to.array.some.key.in.the.array

This allow you to do things like length comparison on the actual array, but also select a subfield.

It adds the some, all, none operators and uses custom jsonlogic processor to generate the some field in json logic. The logic will coerse comma separate value to an array, it will also add 'arrayKey' to the some condition if the array items are an object (eg { var: "my.path" }. if they are simple values then it will just use { var: "" }

import React, { useState } from 'react';
import { MaterialActionElement } from '@react-querybuilder/material';
import { forEach, isObject } from 'lodash'
import { defaultOperators } from 'react-querybuilder'

const jsonSchema = {
  "type": "object",
  "properties": {
      "provider": {
          "type": "string",
          "format": "uri"
      },
      "name": {
          "type": "string"
      },
      "user": {
          "type": "string"
      },
      "metadata": {
          "type": "object",
          "properties": {
              "task": {
                  "type": "object",
                  "properties": {
                      "domain": {
                          "type": "string"
                      },
                      "context": {
                          "type": "object",
                          "properties": {
                              "id": {
                                  "type": "string"
                              },
                              "type": {
                                  "type": "string"
                              }
                          }
                      },
                      "name": {
                          "type": "string"
                      },
                      "notes": {
                          "type": "array",
                          "items": {
                              "type": "object",
                              "properties": {
                                  "user": {
                                      "type": "object",
                                      "properties": {
                                          "id": {
                                              "type": "string"
                                          }
                                      }
                                  },
                                  "description": {
                                      "type": "object",
                                      "properties": {
                                          "plainText": {
                                              "type": "string"
                                          },
                                          "html": {
                                              "type": "string"
                                          }
                                      }
                                  },
                                  "createdAt": {
                                      "type": "string",
                                      "format": "date-time"
                                  },
                                  "id": {
                                      "type": "string"
                                  },
                              }
                          }
                      },
                      "createdBy": {
                          "type": "object",
                          "properties": {
                              "context": {
                                  "type": "object",
                                  "properties": {
                                      "type": {
                                          "type": "string"
                                      },
                                      "id": {
                                          "type": "string"
                                      }
                                  }
                              },
                              "user": {
                                  "type": "object",
                                  "properties": {
                                      "id": {
                                          "type": "string"
                                      }
                                  }
                              }
                          }
                      },
                      "deadline": {
                          "type": "string",
                          "format": "date-time"
                      },
                      "status": {
                          "type": "string"
                      },
                      "schedule": {
                          "type": "object",
                          "properties": {
                              "id": {
                                  "type": "string"
                              },
                              "provider": {
                                  "type": "string"
                              },
                              "scheduledBy": {
                                  "type": "object",
                                  "properties": {
                                      "id": {
                                          "type": "string"
                                      }
                                  }
                              },
                              "subject": {
                                  "type": "string"
                              },
                              "startTime": {
                                  "type": "string",
                                  "format": "date-time"
                              },
                              "endTime": {
                                  "type": "string",
                                  "format": "date-time"
                              }
                          }
                      },
                      "checklistItems": {
                          "type": "array",
                          "items": {
                              "type": "object",
                              "properties": {
                                  "name": {
                                      "type": "string"
                                  },
                                  "finishedAt": {
                                      "type": "string",
                                      "format": "date-time"
                                  },
                                  "createdAt": {
                                      "type": "string",
                                      "format": "date-time"
                                  },
                                  "updatedAt": {
                                      "type": "string",
                                      "format": "date-time"
                                  },
                                  "id": {
                                      "type": "string"
                                  },
                                  "checked": {
                                      "type": "boolean"
                                  }
                              }
                          }
                      },
                      "createdAt": {
                          "type": "string",
                          "format": "date-time"
                      },
                      "updatedAt": {
                          "type": "string",
                          "format": "date-time"
                      },
                      "id": {
                          "type": "string"
                      },
                      "attachments": {
                          "type": "array"
                      },
                      "tags": {
                          "type": "array",
                          "items": {
                              "type": "object",
                              "properties": {
                                  "label": {
                                      "type": "string"
                                  },
                                  "id": {
                                      "type": "string"
                                  }
                              }
                          }
                      }
                  }
              },
              "name": {
                  "type": "string"
              }
          }
      },
      "context": {
          "type": "object",
          "properties": {
              "id": {
                  "type": "string"
              },
              "type": {
                  "type": "string"
              }
          }
      },
      "actionsList": {
          "type": "array",
          "items": {
              "type": "string"
          }
      },
      "time_iso8601": {
          "type": "string",
          "format": "date-time"
      },
      "updatedAt": {
          "type": "string",
          "format": "date-time"
      },
      "id": {
          "type": "string"
      },
      "key": {
          "type": "object",
          "properties": {
              "provider_name": {
                  "type": "string",
                  "format": "uri"
              },
              "time_iso8601_id": {
                  "type": "string"
              }
          }
      }
  }
}

const getEditorType = (format, type) => {
  if (format === "date-time") return 'datetime-local'
  if (type === 'integer') return 'number'
  return 'text'
}

const getOperators = (format, type) => {
  if (type === 'array') {
    return [
      { name: 'isLength', label: 'size' },
      { name: 'isEqual', label: '=' },
      { name: 'doesNotContain', label: 'not contain' },
      { name: 'contains', label: 'contains' }
    ]
  }
  return defaultOperators
}

const fields = []
const processField = (parentPath='', arrayPath='') => (props, key) => {

  if (isObject(props)) {
    const { type, format, items, properties } = props

    if (!type && key === 'properties') {
      forEach(props, processField(parentPath, arrayPath))
    }

    const path = `${parentPath.length ? `${parentPath}.` : ''}${key}`

    if (type === 'array' && items?.type === 'object') {
      fields.push({
        name: path,
        type: `event.updated-task.${type}${format ? `.${format}` : ''}`,
        label: path,
        valueSources: ['field', 'value'],
        comparator: f => f.type === `event.updated-task.${type}${format ? `.${format}` : ''}`
      })

      forEach(items?.properties, processField(path, path))
      return
    }

    if (type === 'object' && properties) {
      forEach(properties, processField(path, arrayPath))
      return
    }


    fields.push({
      name: path,
      type: `event.updated-task.${type}${format ? `.${format}` : ''}`,
      arrayPath: arrayPath.length ? arrayPath : undefined,
      arrayKey: arrayPath.length ? path.substring(arrayPath.length+1) : undefined,
      label: path,
      operators: getOperators(format, type),
      valueSources: ['field', 'value'],
      valueEditorType: getEditorType(format, type),
      comparator: f => f.type === `event.updated-task.${type}${format ? `.${format}` : ''}`
    })
  }
}

forEach(jsonSchema, processField(''))

const AddRuleAction = props => {

  const context = {
    ...props.context,
    rule: {
      value: '',
      getField: props.context.getField
    }
  }

  return <MaterialActionElement
    {...props}
    handleOnClick={e => props.handleOnClick(e, context)}
  />
}

const onAddRule = (rule, parentPath, query, context) => {
  return {
    ...rule,
    ...(context?.rule || {})
  }
}

const getField = field => fields.find(f => f.name === field)

const Builder = props => {

  const [query, setQuery] = useState({
    combinator: 'and',
    not: false,
    rules: []
  })

  return <QueryBuilder
    fields={fields}
    defaultQuery={query}
    context={{ query, getEventField }}
    onQueryChange={setQuery}
    listsAsArrays
    controlClassnames={{ queryBuilder: 'queryBuilder-branches' }}
    controlElements={{
      addRuleAction: AddRuleAction,
    }}
    onAddRule={onAddRule}
    operators={[
      ...defaultOperators,
      { name: 'isLength', label: 'size' },
      { name: 'isEqual', label: '=' },
      { name: 'some', label: 'some' },
      { name: 'all', label: 'all' },
      { name: 'none', label: 'none' }
    ]}
  />
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants