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

feat: add native driver support #54

Merged
merged 6 commits into from
Jun 10, 2024
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
2 changes: 2 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ Metrics/MethodLength:
- 'packages/forest_admin_agent/lib/forest_admin_agent/serializer/json_api_serializer.rb'
- 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/parser/validation.rb'
- 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb'
- 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb'
- 'packages/forest_admin_datasource_active_record/spec/dummy/**/*'
- 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/computed/utils/flattener.rb'
- 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/decorators_stack.rb'
Expand Down Expand Up @@ -238,6 +239,7 @@ Metrics/ClassLength:
- 'packages/forest_admin_agent/lib/forest_admin_agent/services/permissions.rb'
- 'packages/forest_admin_agent/lib/forest_admin_agent/routes/charts/charts.rb'
- 'packages/forest_admin_agent/lib/forest_admin_agent/routes/action/action.rb'
- 'packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/frontend_validation_utils.rb'
- 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/utils/query.rb'
- 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/collection_customizer.rb'
- 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/binary/binary_collection_decorator.rb'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,11 @@ class FrontendValidationUtils
def self.convert_validation_list(column)
return [] if column.validations.empty?

rules = column.validations.dup.map { |rule| simplify_rule(column.column_type, rule) }
remove_duplicates_in_place(rules).map { |rule| SUPPORTED[rule[:operator]].call(rule) }
rules = column.validations.map { |rule| simplify_rule(column.column_type, rule) }
remove_duplicates_in_place(rules)

rules.filter { |rule| rule.is_a?(Hash) && rule.key?(:operator) }
.map { |rule| SUPPORTED[rule[:operator]].call(rule) }
end

def self.simplify_rule(column_type, rule)
Expand All @@ -74,11 +77,16 @@ def self.simplify_rule(column_type, rule)
timezone = 'Europe/Paris' # we're sending the schema => use random tz
tree = ConditionTreeEquivalent.get_equivalent_tree(leaf, operators, column_type, timezone)

if tree.is_a? Nodes::ConditionTreeLeaf
[tree]
else
tree.conditions
end
conditions = if tree.is_a? Nodes::ConditionTreeLeaf
[tree]
else
tree.conditions
end

return conditions.filter { |c| c.is_a?(Nodes::ConditionTreeLeaf) }
.filter { |c| c.operator != Operators::EQUAL && c.operator != Operators::NOT_EQUAL }
.map { |c| simplify_rule(column_type, operator: c.operator, value: c.value) }
.first
rescue StandardError
# Just ignore errors, they mean that the operator is not supported by the frontend
# and that we don't have an automatic conversion for it.
Expand All @@ -91,36 +99,44 @@ def self.simplify_rule(column_type, rule)
[]
end

# The frontend crashes when it receives multiple rules of the same type.
# This method merges the rules which can be merged and drops the others.
def self.remove_duplicates_in_place(rules)
used = {}
rules.each_with_index do |rule, key|
if used.key?(rule[:operator])
rule = rules[rule[:operator]]
new_rule = rule
rules.delete(key)
rules[used[rule[:operator]]] = merge_into(rule, new_rule)
else
used[rule[:operator]] = key

i = 0
while i < rules.length
rule = rules[i]
if rule.is_a?(Hash) && rule.key?(:operator)
if used.key?(rule[:operator])
existing_rule = rules[used[rule[:operator]]]
new_rule = rules.delete_at(i)

merge_into(existing_rule, new_rule)
# Adjust the index to account for the removed element
i -= 1
else
used[rule[:operator]] = i
end
end
i += 1
end

rules
end

def merge_into(rule, new_rule)
if [Operators::GREATER_THAN, Operators::AFTER, Operators::LONGER_THAN].include? rule[:operator]
# rubocop:disable Style/EmptyElse
def self.merge_into(rule, new_rule)
case rule[:operator]
when Operators::GREATER_THAN, Operators::AFTER, Operators::LONGER_THAN
rule[:value] = [rule[:value], new_rule[:value]].max
elsif [Operators::LESS_THAN, Operators::BEFORE, Operators::SHORTER_THAN].include? rule[:operator]
when Operators::LESS_THAN, Operators::BEFORE, Operators::SHORTER_THAN
rule[:value] = [rule[:value], new_rule[:value]].min
elsif rule[:operator] == Operators::MATCH
# TODO
when Operators::MATCH
regex = rule[:value].gsub(/\W/, '')
new_regex = new_rule[:value].gsub(/\W/, '')
rule[:value] = "/^(?=#{regex})(?=#{new_regex}).*$/i"
else
# Ignore the rules that we can't deduplicate (we could log a warning here).
end
# else Ignore the rules that we can't deduplicate (we could log a warning here).

rule
end
# rubocop:enable Style/EmptyElse
end
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
require 'spec_helper'

module ForestAdminAgent
module Utils
module Schema
include ForestAdminDatasourceToolkit::Components::Query::ConditionTree

describe FrontendValidationUtils do
subject(:frontend_validation_utils) { described_class }

context 'when using convert_validation_list' do
it 'works with null validation' do
column = column_build
expect(frontend_validation_utils.convert_validation_list(column)).to be_empty
end

it 'works with empty validation' do
column = column_build(validations: [])
expect(frontend_validation_utils.convert_validation_list(column)).to be_empty
end

it 'works with supported handlers (strings)' do
column = numeric_primary_key_build(validations: [
{ operator: Operators::PRESENT },
{ operator: Operators::LESS_THAN, value: 34 },
{ operator: Operators::GREATER_THAN, value: 60 }
])
expect(frontend_validation_utils.convert_validation_list(column)).to eq([
{ message: 'Field is required', type: 'is present' },
{ message: 'Value must be lower than 34', type: 'is less than', value: 34 },
{ message: 'Value must be greater than 60', type: 'is greater than', value: 60 }
])
end

it 'works with supported handlers (date)' do
column = column_build(column_type: 'Date', validations: [
{ operator: Operators::BEFORE, value: '2010-01-01T00:00:00Z' },
{ operator: Operators::AFTER, value: '2010-01-01T00:00:00Z' }
])
expect(frontend_validation_utils.convert_validation_list(column)).to eq([
{ message: 'Value must be before 2010-01-01T00:00:00Z', type: 'is before', value: '2010-01-01T00:00:00Z' },
{ message: 'Value must be after 2010-01-01T00:00:00Z', type: 'is after', value: '2010-01-01T00:00:00Z' }
])
end

it 'works with supported handlers (string)' do
column = column_build(column_type: 'Number', validations: [
{ operator: Operators::LONGER_THAN, value: 34 },
{ operator: Operators::SHORTER_THAN, value: 60 },
{ operator: Operators::CONTAINS, value: 'abc' },
{ operator: Operators::MATCH, value: '/abc/' }
])
expect(frontend_validation_utils.convert_validation_list(column)).to eq([
{ message: 'Value must be longer than 34 characters', type: 'is longer than', value: 34 },
{ message: 'Value must be shorter than 60 characters', type: 'is shorter than', value: 60 },
{ message: 'Value must contain abc', type: 'is contains', value: 'abc' },
{ message: 'Value must match /abc/', type: 'is like', value: '/abc/' }
])
end

it 'works with supported handlers (fake enum)' do
column = column_build(column_type: 'String', validations: [
{ operator: Operators::IN, value: %w[a b c] }
])
expect(frontend_validation_utils.convert_validation_list(column)).to eq([
{ message: 'Value must match /(a|b|c)/g', type: 'is like', value: '/(a|b|c)/g' }
])
end

it 'works with duplication' do
column = numeric_primary_key_build(validations: [
{ operator: Operators::PRESENT },
{ operator: Operators::PRESENT },
{ operator: Operators::LESS_THAN, value: 34 },
{ operator: Operators::LESS_THAN, value: 40 },
{ operator: Operators::GREATER_THAN, value: 60 },
{ operator: Operators::GREATER_THAN, value: 80 },
{ operator: Operators::GREATER_THAN, value: 70 },
{ operator: Operators::MATCH, value: '/a/' },
{ operator: Operators::MATCH, value: '/b/' }
])
expect(frontend_validation_utils.convert_validation_list(column)).to eq([
{ message: 'Field is required', type: 'is present' },
{ message: 'Value must be lower than 34', type: 'is less than', value: 34 },
{ message: 'Value must be greater than 80', type: 'is greater than', value: 80 },
{ message: 'Value must match /^(?=a)(?=b).*$/i', type: 'is like', value: '/^(?=a)(?=b).*$/i' }
])
end

it 'works with rule expansion (not in with null)' do
column = column_build(column_type: 'String', validations: [
{ operator: Operators::NOT_IN, value: ['a', 'b', nil] }
])
expect(frontend_validation_utils.convert_validation_list(column)).to eq([
{ message: 'Value must match /(?!(a|b))/g', type: 'is like', value: '/(?!(a|b))/g' }
])
end

it 'skips validation which cannot be translated (depends on current time)' do
column = column_build(column_type: 'Date', validations: [
{ operator: Operators::PREVIOUS_QUARTER }
])
expect(frontend_validation_utils.convert_validation_list(column)).to be_empty
end

it 'skips validation which cannot be translated (fake enum with null)' do
column = column_build(column_type: 'String', validations: [
{ operator: Operators::IN, value: ['a', 'b', nil] }
])
expect(frontend_validation_utils.convert_validation_list(column)).to eq([
{ message: 'Value must match /(a|b)/g', type: 'is like', value: '/(a|b)/g' }
])
end
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ def initialize(datasource, model)
enable_count
end

def native_driver
ActiveRecord::Base.connection
end

def list(_caller, filter, projection)
query = Utils::Query.new(self, projection, filter)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def compute_main_operator(condition_tree, aggregator)
when Operators::LIKE
@query = @query.send(aggregator, @query.where(@arel_table[field.to_sym].matches(value)))
when Operators::INCLUDES_ALL
# TODO: to implement
@query = @query.send(aggregator, @query.where(@arel_table[field.to_sym].matches_all(value)))
end

@query
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
require 'spec_helper'
require 'shared/caller'

module ForestAdminDatasourceCustomizer
module Context
module RelaxedWrappers
include ForestAdminDatasourceToolkit
include ForestAdminDatasourceToolkit::Decorators
include ForestAdminDatasourceToolkit::Schema
include ForestAdminDatasourceToolkit::Components::Query::ConditionTree

describe RelaxedCollection do
include_context 'with caller'
subject(:relaxed_collection) { described_class }
let(:compute_collection_decorator) { ForestAdminDatasourceCustomizer::Decorators::Computed::ComputeCollectionDecorator }

before do
datasource = Datasource.new
@collection_book = collection_build(
name: 'book',
schema: {
fields: {
'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true),
'title' => ColumnSchema.new(column_type: 'String', filter_operators: [Operators::LONGER_THAN, Operators::PRESENT])
}
}
)

datasource.add_collection(@collection_book)
datasource_decorator = DatasourceDecorator.new(datasource, compute_collection_decorator)

@new_books = datasource_decorator.get_collection('book')
end

context 'when native_driver is called' do
it 'returns the native driver' do
allow(@new_books).to receive(:native_driver).and_return('a native driver')
relaxed_collection = described_class.new(@new_books, caller)

expect(relaxed_collection.native_driver).to eq('a native driver')
end
end

context 'when schema is called' do
it 'returns the schema' do
allow(@new_books).to receive(:schema).and_return({ fields: { 'id' => ColumnSchema.new(column_type: 'Number', is_primary_key: true) } })
relaxed_collection = described_class.new(@new_books, caller)

expect(relaxed_collection.schema).to be_instance_of(Hash)
expect(relaxed_collection.schema).to include(:fields)
expect(relaxed_collection.schema[:fields]).to have_key('id')
end
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class Collection < Components::Contracts::CollectionContract
:schema,
:native_driver

def initialize(datasource, name, native_driver: nil)
def initialize(datasource, name, native_driver = nil)
super()
@datasource = datasource
@name = name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def initialize(child_collection, datasource)
end

def native_driver
# TODO
child_collection.native_driver
end

def schema
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
require 'spec_helper'

module ForestAdminDatasourceToolkit
module Decorators
describe CollectionDecorator do
before do
datasource = Datasource.new
@collection_book = collection_build(
name: 'book',
schema: {
fields: {
'id' => ForestAdminDatasourceToolkit::Schema::ColumnSchema.new(column_type: 'Number', is_primary_key: true),
'title' => ForestAdminDatasourceToolkit::Schema::ColumnSchema.new(column_type: 'String')
}
}
)

datasource.add_collection(@collection_book)
end

context 'when native_driver is called' do
it 'returns the native driver' do
allow(@collection_book).to receive(:native_driver).and_return('a native driver')

expect(@collection_book.native_driver).to eq('a native driver')
end
end
end
end
end
Loading