@the-trybe/formula-engine 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -657,4 +657,536 @@ describe('FormulaEngine', () => {
657
657
  expect(result.value).toBe('Value: 42');
658
658
  });
659
659
  });
660
+
661
+ // ==========================================================================
662
+ // Object Literals
663
+ // ==========================================================================
664
+
665
+ describe('Object Literals', () => {
666
+ it('should evaluate simple object literal', () => {
667
+ const result = engine.evaluate('{ a: 1, b: 2 }', { variables: {} });
668
+
669
+ expect(result.success).toBe(true);
670
+ const obj = result.value as Record<string, any>;
671
+ expect((obj.a as Decimal).toNumber()).toBe(1);
672
+ expect((obj.b as Decimal).toNumber()).toBe(2);
673
+ });
674
+
675
+ it('should evaluate object literal with variable values', () => {
676
+ const result = engine.evaluate('{ price: $p, qty: $q }', {
677
+ variables: { p: 10, q: 5 },
678
+ });
679
+
680
+ expect(result.success).toBe(true);
681
+ const obj = result.value as Record<string, any>;
682
+ expect((obj.price as Decimal).toNumber()).toBe(10);
683
+ expect((obj.qty as Decimal).toNumber()).toBe(5);
684
+ });
685
+
686
+ it('should evaluate empty object literal', () => {
687
+ const result = engine.evaluate('{}', { variables: {} });
688
+
689
+ expect(result.success).toBe(true);
690
+ expect(result.value).toEqual({});
691
+ });
692
+
693
+ it('should evaluate object literal with expression values', () => {
694
+ const result = engine.evaluate('{ total: $a + $b }', {
695
+ variables: { a: 10, b: 20 },
696
+ });
697
+
698
+ expect(result.success).toBe(true);
699
+ const obj = result.value as Record<string, any>;
700
+ expect((obj.total as Decimal).toNumber()).toBe(30);
701
+ });
702
+
703
+ it('should evaluate object literal with context variables', () => {
704
+ const result = engine.evaluate('{ zone: @zone }', {
705
+ variables: {},
706
+ extra: { zone: 'A' },
707
+ });
708
+
709
+ expect(result.success).toBe(true);
710
+ const obj = result.value as Record<string, any>;
711
+ expect(obj.zone).toBe('A');
712
+ });
713
+
714
+ it('should evaluate object literal with mixed value types', () => {
715
+ const result = engine.evaluate('{ name: "test", count: 42, active: true, data: null }', {
716
+ variables: {},
717
+ });
718
+
719
+ expect(result.success).toBe(true);
720
+ const obj = result.value as Record<string, any>;
721
+ expect(obj.name).toBe('test');
722
+ expect((obj.count as Decimal).toNumber()).toBe(42);
723
+ expect(obj.active).toBe(true);
724
+ expect(obj.data).toBe(null);
725
+ });
726
+ });
727
+
728
+ // ==========================================================================
729
+ // LOOKUP Function
730
+ // ==========================================================================
731
+
732
+ describe('LOOKUP Function', () => {
733
+ const taxRates = [
734
+ { region: 'US', category: 'electronics', rate: 0.08 },
735
+ { region: 'US', category: 'food', rate: 0.02 },
736
+ { region: 'EU', category: 'electronics', rate: 0.20 },
737
+ { region: 'EU', category: 'food', rate: 0.10 },
738
+ ];
739
+
740
+ // --- Basic scenarios ---
741
+
742
+ it('should find matching row with single criteria', () => {
743
+ const shopRates = [
744
+ { class: 'A', redevance: 100 },
745
+ { class: 'B', redevance: 200 },
746
+ { class: 'C', redevance: 300 },
747
+ ];
748
+ const result = engine.evaluate(
749
+ 'LOOKUP($table, { class: "B" }, "redevance")',
750
+ { variables: { table: shopRates } }
751
+ );
752
+
753
+ expect(result.success).toBe(true);
754
+ // Table data numbers are auto-converted to Decimal by normalizeContext
755
+ expect((result.value as Decimal).toNumber()).toBe(200);
756
+ });
757
+
758
+ it('should find matching row with multi-dimension criteria', () => {
759
+ const result = engine.evaluate(
760
+ 'LOOKUP($table, { region: "EU", category: "food" }, "rate")',
761
+ { variables: { table: taxRates } }
762
+ );
763
+
764
+ expect(result.success).toBe(true);
765
+ expect((result.value as Decimal).toNumber()).toBe(0.10);
766
+ });
767
+
768
+ it('should return string field value', () => {
769
+ const data = [
770
+ { code: 'A', label: 'Alpha' },
771
+ { code: 'B', label: 'Beta' },
772
+ ];
773
+ const result = engine.evaluate(
774
+ 'LOOKUP($data, { code: "B" }, "label")',
775
+ { variables: { data } }
776
+ );
777
+
778
+ expect(result.success).toBe(true);
779
+ expect(result.value).toBe('Beta');
780
+ });
781
+
782
+ it('should return numeric field value', () => {
783
+ const result = engine.evaluate(
784
+ 'LOOKUP($table, { region: "US", category: "electronics" }, "rate")',
785
+ { variables: { table: taxRates } }
786
+ );
787
+
788
+ expect(result.success).toBe(true);
789
+ expect((result.value as Decimal).toNumber()).toBe(0.08);
790
+ });
791
+
792
+ // --- Edge cases ---
793
+
794
+ it('should return 0 when no match found', () => {
795
+ const result = engine.evaluate(
796
+ 'LOOKUP($table, { region: "JP", category: "electronics" }, "rate")',
797
+ { variables: { table: taxRates } }
798
+ );
799
+
800
+ expect(result.success).toBe(true);
801
+ expect(result.value).toBe(0);
802
+ });
803
+
804
+ it('should return 0 for null table', () => {
805
+ const result = engine.evaluate(
806
+ 'LOOKUP(null, { region: "US" }, "rate")',
807
+ { variables: {} }
808
+ );
809
+
810
+ expect(result.success).toBe(true);
811
+ expect(result.value).toBe(0);
812
+ });
813
+
814
+ it('should return 0 for undefined table variable (non-strict)', () => {
815
+ const lenientEngine = new FormulaEngine({ strictMode: false });
816
+ const result = lenientEngine.evaluate(
817
+ 'LOOKUP($table, { region: "US" }, "rate")',
818
+ { variables: {} }
819
+ );
820
+
821
+ expect(result.success).toBe(true);
822
+ expect(result.value).toBe(0);
823
+ });
824
+
825
+ it('should return first match when multiple rows match', () => {
826
+ const data = [
827
+ { type: 'X', value: 'first' },
828
+ { type: 'X', value: 'second' },
829
+ ];
830
+ const result = engine.evaluate(
831
+ 'LOOKUP($data, { type: "X" }, "value")',
832
+ { variables: { data } }
833
+ );
834
+
835
+ expect(result.success).toBe(true);
836
+ expect(result.value).toBe('first');
837
+ });
838
+
839
+ it('should match first row with empty criteria', () => {
840
+ const data = [
841
+ { a: 1, b: 'first' },
842
+ { a: 2, b: 'second' },
843
+ ];
844
+ const result = engine.evaluate(
845
+ 'LOOKUP($data, {}, "b")',
846
+ { variables: { data } }
847
+ );
848
+
849
+ expect(result.success).toBe(true);
850
+ expect(result.value).toBe('first');
851
+ });
852
+
853
+ it('should return 0 when returnField is missing from matched row', () => {
854
+ const data = [
855
+ { type: 'A', name: 'Alpha' },
856
+ ];
857
+ const result = engine.evaluate(
858
+ 'LOOKUP($data, { type: "A" }, "nonexistent")',
859
+ { variables: { data } }
860
+ );
861
+
862
+ expect(result.success).toBe(true);
863
+ expect(result.value).toBe(0);
864
+ });
865
+
866
+ it('should return 0 for empty table', () => {
867
+ const result = engine.evaluate(
868
+ 'LOOKUP($table, { region: "US" }, "rate")',
869
+ { variables: { table: [] } }
870
+ );
871
+
872
+ expect(result.success).toBe(true);
873
+ expect(result.value).toBe(0);
874
+ });
875
+
876
+ it('should ignore extra fields in table rows', () => {
877
+ const data = [
878
+ { type: 'A', extra1: 'ignore', extra2: 999, value: 42 },
879
+ ];
880
+ const result = engine.evaluate(
881
+ 'LOOKUP($data, { type: "A" }, "value")',
882
+ { variables: { data } }
883
+ );
884
+
885
+ expect(result.success).toBe(true);
886
+ expect((result.value as Decimal).toNumber()).toBe(42);
887
+ });
888
+
889
+ it('should work with criteria from context variables', () => {
890
+ const result = engine.evaluate(
891
+ 'LOOKUP($table, { region: @region, category: @category }, "rate")',
892
+ {
893
+ variables: { table: taxRates },
894
+ extra: { region: 'EU', category: 'electronics' },
895
+ }
896
+ );
897
+
898
+ expect(result.success).toBe(true);
899
+ expect((result.value as Decimal).toNumber()).toBe(0.20);
900
+ });
901
+
902
+ // --- Real-world scenario ---
903
+
904
+ it('should handle cafe coefficient lookup (4 dimensions)', () => {
905
+ const cafeCoefficients = [
906
+ { type: 'Cafe', equipment: 'TV', zone: '1', service: 'standard', redevance: 120.50 },
907
+ { type: 'Cafe', equipment: 'TV', zone: '2', service: 'standard', redevance: 95.30 },
908
+ { type: 'Cafe', equipment: 'Radio', zone: '1', service: 'standard', redevance: 60.00 },
909
+ { type: 'Restaurant', equipment: 'TV', zone: '1', service: 'standard', redevance: 200.00 },
910
+ { type: 'Cafe', equipment: 'TV', zone: '1', service: 'premium', redevance: 180.75 },
911
+ ];
912
+ const result = engine.evaluate(
913
+ 'LOOKUP(@tenant.cafeCoefficients, { type: @client.type, equipment: @client.equipment, zone: @client.zone, service: @client.service }, "redevance")',
914
+ {
915
+ variables: {},
916
+ extra: {
917
+ tenant: { cafeCoefficients },
918
+ client: { type: 'Cafe', equipment: 'TV', zone: '2', service: 'standard' },
919
+ },
920
+ }
921
+ );
922
+
923
+ expect(result.success).toBe(true);
924
+ // Context extra values are NOT auto-converted to Decimal, so raw number is returned
925
+ expect(result.value).toBe(95.30);
926
+ });
927
+ });
928
+
929
+ // ==========================================================================
930
+ // RANGE Function
931
+ // ==========================================================================
932
+
933
+ describe('RANGE Function', () => {
934
+ const tiers = [
935
+ { min: 0, max: 1000, rate: 0.10 },
936
+ { min: 1000, max: 5000, rate: 0.15 },
937
+ { min: 5000, max: null, rate: 0.20 },
938
+ ];
939
+
940
+ // --- Basic scenarios ---
941
+
942
+ it('should match first tier', () => {
943
+ const result = engine.evaluate(
944
+ 'RANGE($tiers, 500, "min", "max", "rate")',
945
+ { variables: { tiers } }
946
+ );
947
+
948
+ expect(result.success).toBe(true);
949
+ expect((result.value as Decimal).toNumber()).toBe(0.10);
950
+ });
951
+
952
+ it('should match middle tier', () => {
953
+ const result = engine.evaluate(
954
+ 'RANGE($tiers, 2500, "min", "max", "rate")',
955
+ { variables: { tiers } }
956
+ );
957
+
958
+ expect(result.success).toBe(true);
959
+ expect((result.value as Decimal).toNumber()).toBe(0.15);
960
+ });
961
+
962
+ it('should match unbounded tier (null max)', () => {
963
+ const result = engine.evaluate(
964
+ 'RANGE($tiers, 10000, "min", "max", "rate")',
965
+ { variables: { tiers } }
966
+ );
967
+
968
+ expect(result.success).toBe(true);
969
+ expect((result.value as Decimal).toNumber()).toBe(0.20);
970
+ });
971
+
972
+ // --- Boundary conditions ---
973
+
974
+ it('should match at inclusive min boundary', () => {
975
+ const result = engine.evaluate(
976
+ 'RANGE($tiers, 1000, "min", "max", "rate")',
977
+ { variables: { tiers } }
978
+ );
979
+
980
+ expect(result.success).toBe(true);
981
+ expect((result.value as Decimal).toNumber()).toBe(0.15);
982
+ });
983
+
984
+ it('should not match at exclusive max boundary (falls to next tier)', () => {
985
+ // 5000 is the max of tier 2 (exclusive), and the min of tier 3 (inclusive)
986
+ const result = engine.evaluate(
987
+ 'RANGE($tiers, 5000, "min", "max", "rate")',
988
+ { variables: { tiers } }
989
+ );
990
+
991
+ expect(result.success).toBe(true);
992
+ expect((result.value as Decimal).toNumber()).toBe(0.20);
993
+ });
994
+
995
+ it('should match value just below max boundary', () => {
996
+ const result = engine.evaluate(
997
+ 'RANGE($tiers, 4999.99, "min", "max", "rate")',
998
+ { variables: { tiers } }
999
+ );
1000
+
1001
+ expect(result.success).toBe(true);
1002
+ expect((result.value as Decimal).toNumber()).toBe(0.15);
1003
+ });
1004
+
1005
+ it('should match at zero', () => {
1006
+ const result = engine.evaluate(
1007
+ 'RANGE($tiers, 0, "min", "max", "rate")',
1008
+ { variables: { tiers } }
1009
+ );
1010
+
1011
+ expect(result.success).toBe(true);
1012
+ expect((result.value as Decimal).toNumber()).toBe(0.10);
1013
+ });
1014
+
1015
+ it('should match large value in unbounded tier', () => {
1016
+ const result = engine.evaluate(
1017
+ 'RANGE($tiers, 999999, "min", "max", "rate")',
1018
+ { variables: { tiers } }
1019
+ );
1020
+
1021
+ expect(result.success).toBe(true);
1022
+ expect((result.value as Decimal).toNumber()).toBe(0.20);
1023
+ });
1024
+
1025
+ // --- Edge cases ---
1026
+
1027
+ it('should return 0 when value below all bands', () => {
1028
+ const result = engine.evaluate(
1029
+ 'RANGE($tiers, -5, "min", "max", "rate")',
1030
+ { variables: { tiers } }
1031
+ );
1032
+
1033
+ expect(result.success).toBe(true);
1034
+ expect(result.value).toBe(0);
1035
+ });
1036
+
1037
+ it('should return 0 for null table', () => {
1038
+ const result = engine.evaluate(
1039
+ 'RANGE(null, 500, "min", "max", "rate")',
1040
+ { variables: {} }
1041
+ );
1042
+
1043
+ expect(result.success).toBe(true);
1044
+ expect(result.value).toBe(0);
1045
+ });
1046
+
1047
+ it('should return 0 for undefined table variable (non-strict)', () => {
1048
+ const lenientEngine = new FormulaEngine({ strictMode: false });
1049
+ const result = lenientEngine.evaluate(
1050
+ 'RANGE($tiers, 500, "min", "max", "rate")',
1051
+ { variables: {} }
1052
+ );
1053
+
1054
+ expect(result.success).toBe(true);
1055
+ expect(result.value).toBe(0);
1056
+ });
1057
+
1058
+ it('should return 0 for empty table', () => {
1059
+ const result = engine.evaluate(
1060
+ 'RANGE($tiers, 500, "min", "max", "rate")',
1061
+ { variables: { tiers: [] } }
1062
+ );
1063
+
1064
+ expect(result.success).toBe(true);
1065
+ expect(result.value).toBe(0);
1066
+ });
1067
+
1068
+ it('should return 0 when value above all bounded tiers', () => {
1069
+ const boundedTiers = [
1070
+ { min: 0, max: 100, rate: 0.05 },
1071
+ { min: 100, max: 500, rate: 0.10 },
1072
+ ];
1073
+ const result = engine.evaluate(
1074
+ 'RANGE($tiers, 1000, "min", "max", "rate")',
1075
+ { variables: { tiers: boundedTiers } }
1076
+ );
1077
+
1078
+ expect(result.success).toBe(true);
1079
+ expect(result.value).toBe(0);
1080
+ });
1081
+
1082
+ // --- Real-world scenarios ---
1083
+
1084
+ it('should resolve room price to reference price (5-tier table)', () => {
1085
+ const roomPriceBands = [
1086
+ { min: 0, max: 500, referencePrice: 350 },
1087
+ { min: 500, max: 800, referencePrice: 700 },
1088
+ { min: 800, max: 1500, referencePrice: 1250 },
1089
+ { min: 1500, max: 3000, referencePrice: 2250 },
1090
+ { min: 3000, max: null, referencePrice: 3000 },
1091
+ ];
1092
+
1093
+ // Test various price points
1094
+ const test = (price: number, expectedRef: number) => {
1095
+ const result = engine.evaluate(
1096
+ 'RANGE($bands, $price, "min", "max", "referencePrice")',
1097
+ { variables: { bands: roomPriceBands, price } }
1098
+ );
1099
+ expect(result.success).toBe(true);
1100
+ expect((result.value as Decimal).toNumber()).toBe(expectedRef);
1101
+ };
1102
+
1103
+ test(250, 350); // First band
1104
+ test(650, 700); // Second band
1105
+ test(1200, 1250); // Third band
1106
+ test(2000, 2250); // Fourth band
1107
+ test(5000, 3000); // Unbounded band
1108
+ });
1109
+
1110
+ it('should resolve tax bracket', () => {
1111
+ const taxBrackets = [
1112
+ { min: 0, max: 10000, taxRate: 0 },
1113
+ { min: 10000, max: 25000, taxRate: 0.10 },
1114
+ { min: 25000, max: 50000, taxRate: 0.20 },
1115
+ { min: 50000, max: null, taxRate: 0.30 },
1116
+ ];
1117
+ const result = engine.evaluate(
1118
+ 'RANGE($brackets, 35000, "min", "max", "taxRate")',
1119
+ { variables: { brackets: taxBrackets } }
1120
+ );
1121
+
1122
+ expect(result.success).toBe(true);
1123
+ expect((result.value as Decimal).toNumber()).toBe(0.20);
1124
+ });
1125
+ });
1126
+
1127
+ // ==========================================================================
1128
+ // evaluateAll Integration with LOOKUP and RANGE
1129
+ // ==========================================================================
1130
+
1131
+ describe('evaluateAll with LOOKUP and RANGE', () => {
1132
+ it('should use LOOKUP result in downstream formula', () => {
1133
+ const coefficients = [
1134
+ { type: 'A', coeff: 1.5 },
1135
+ { type: 'B', coeff: 2.0 },
1136
+ ];
1137
+
1138
+ const formulas: FormulaDefinition[] = [
1139
+ { id: 'coeff', expression: 'LOOKUP($coefficients, { type: $clientType }, "coeff")' },
1140
+ { id: 'total', expression: '$base * $coeff' },
1141
+ ];
1142
+
1143
+ const results = engine.evaluateAll(formulas, {
1144
+ variables: { coefficients, clientType: 'B', base: 100 },
1145
+ });
1146
+
1147
+ expect(results.success).toBe(true);
1148
+ expect((results.results.get('coeff')?.value as Decimal).toNumber()).toBe(2.0);
1149
+ expect((results.results.get('total')?.value as Decimal).toNumber()).toBe(200);
1150
+ });
1151
+
1152
+ it('should use RANGE result in downstream formula', () => {
1153
+ const bands = [
1154
+ { min: 0, max: 100, multiplier: 1.0 },
1155
+ { min: 100, max: null, multiplier: 1.5 },
1156
+ ];
1157
+
1158
+ const formulas: FormulaDefinition[] = [
1159
+ { id: 'multiplier', expression: 'RANGE($bands, $amount, "min", "max", "multiplier")' },
1160
+ { id: 'result', expression: '$amount * $multiplier' },
1161
+ ];
1162
+
1163
+ const results = engine.evaluateAll(formulas, {
1164
+ variables: { bands, amount: 150 },
1165
+ });
1166
+
1167
+ expect(results.success).toBe(true);
1168
+ expect((results.results.get('multiplier')?.value as Decimal).toNumber()).toBe(1.5);
1169
+ expect((results.results.get('result')?.value as Decimal).toNumber()).toBe(225);
1170
+ });
1171
+
1172
+ it('should handle LOOKUP with no match defaulting to 0 in downstream formula', () => {
1173
+ const data = [
1174
+ { key: 'exists', value: 10 },
1175
+ ];
1176
+
1177
+ const formulas: FormulaDefinition[] = [
1178
+ { id: 'looked', expression: 'LOOKUP($data, { key: "missing" }, "value")' },
1179
+ { id: 'total', expression: '$base + $looked' },
1180
+ ];
1181
+
1182
+ const results = engine.evaluateAll(formulas, {
1183
+ variables: { data, base: 100 },
1184
+ });
1185
+
1186
+ expect(results.success).toBe(true);
1187
+ // LOOKUP returns plain 0 on no match (not Decimal)
1188
+ expect(results.results.get('looked')?.value).toBe(0);
1189
+ expect((results.results.get('total')?.value as Decimal).toNumber()).toBe(100);
1190
+ });
1191
+ });
660
1192
  });
package/src/functions.ts CHANGED
@@ -746,6 +746,155 @@ export function createBuiltInFunctions(decimalUtils: DecimalUtils): Map<string,
746
746
  },
747
747
  };
748
748
 
749
+ // ============================================================================
750
+ // Table/Lookup Functions
751
+ // ============================================================================
752
+
753
+ const LOOKUP: FunctionDefinition = {
754
+ name: 'LOOKUP',
755
+ minArgs: 3,
756
+ maxArgs: 3,
757
+ returnType: 'any',
758
+ description: 'Multi-criteria exact-match lookup on array of objects',
759
+ implementation: (args) => {
760
+ const table = args[0];
761
+ const criteria = args[1];
762
+ const returnField = args[2];
763
+
764
+ if (table === null || table === undefined) {
765
+ return 0;
766
+ }
767
+
768
+ if (!Array.isArray(table)) {
769
+ throw new TypeMismatchError('array', typeof table, 'LOOKUP');
770
+ }
771
+
772
+ if (typeof criteria !== 'object' || criteria === null || Array.isArray(criteria)) {
773
+ throw new TypeMismatchError('object', typeof criteria, 'LOOKUP criteria');
774
+ }
775
+
776
+ if (typeof returnField !== 'string') {
777
+ throw new TypeMismatchError('string', typeof returnField, 'LOOKUP returnField');
778
+ }
779
+
780
+ const criteriaObj = criteria as Record<string, unknown>;
781
+
782
+ for (const row of table) {
783
+ if (typeof row !== 'object' || row === null) continue;
784
+ const rowObj = row as Record<string, unknown>;
785
+
786
+ let match = true;
787
+ for (const [key, value] of Object.entries(criteriaObj)) {
788
+ const rowVal = rowObj[key];
789
+ if (rowVal instanceof Decimal && value instanceof Decimal) {
790
+ if (!rowVal.equals(value)) {
791
+ match = false;
792
+ break;
793
+ }
794
+ } else if (rowVal instanceof Decimal) {
795
+ if (typeof value === 'number' && rowVal.toNumber() === value) continue;
796
+ if (typeof value === 'string' && rowVal.toString() === value) continue;
797
+ match = false;
798
+ break;
799
+ } else if (value instanceof Decimal) {
800
+ if (typeof rowVal === 'number' && value.toNumber() === rowVal) continue;
801
+ if (typeof rowVal === 'string' && value.toString() === rowVal) continue;
802
+ match = false;
803
+ break;
804
+ } else if (rowVal !== value) {
805
+ match = false;
806
+ break;
807
+ }
808
+ }
809
+
810
+ if (match) {
811
+ const result = rowObj[returnField];
812
+ return result !== undefined ? result : 0;
813
+ }
814
+ }
815
+
816
+ return 0;
817
+ },
818
+ };
819
+
820
+ const RANGE: FunctionDefinition = {
821
+ name: 'RANGE',
822
+ minArgs: 5,
823
+ maxArgs: 5,
824
+ returnType: 'any',
825
+ description: 'Numeric band/tier resolution: min <= inputValue < max',
826
+ implementation: (args) => {
827
+ const table = args[0];
828
+ const inputValue = args[1];
829
+ const minField = args[2];
830
+ const maxField = args[3];
831
+ const returnField = args[4];
832
+
833
+ if (table === null || table === undefined) {
834
+ return 0;
835
+ }
836
+
837
+ if (!Array.isArray(table)) {
838
+ throw new TypeMismatchError('array', typeof table, 'RANGE');
839
+ }
840
+
841
+ if (typeof minField !== 'string') {
842
+ throw new TypeMismatchError('string', typeof minField, 'RANGE minField');
843
+ }
844
+ if (typeof maxField !== 'string') {
845
+ throw new TypeMismatchError('string', typeof maxField, 'RANGE maxField');
846
+ }
847
+ if (typeof returnField !== 'string') {
848
+ throw new TypeMismatchError('string', typeof returnField, 'RANGE returnField');
849
+ }
850
+
851
+ let inputNum: number;
852
+ if (inputValue instanceof Decimal) {
853
+ inputNum = inputValue.toNumber();
854
+ } else if (typeof inputValue === 'number') {
855
+ inputNum = inputValue;
856
+ } else {
857
+ throw new TypeMismatchError('number', typeof inputValue, 'RANGE inputValue');
858
+ }
859
+
860
+ for (const row of table) {
861
+ if (typeof row !== 'object' || row === null) continue;
862
+ const rowObj = row as Record<string, unknown>;
863
+
864
+ const minVal = rowObj[minField];
865
+ const maxVal = rowObj[maxField];
866
+
867
+ let minNum: number;
868
+ if (minVal instanceof Decimal) {
869
+ minNum = minVal.toNumber();
870
+ } else if (typeof minVal === 'number') {
871
+ minNum = minVal;
872
+ } else {
873
+ continue;
874
+ }
875
+
876
+ if (inputNum < minNum) continue;
877
+
878
+ if (maxVal !== null && maxVal !== undefined) {
879
+ let maxNum: number;
880
+ if (maxVal instanceof Decimal) {
881
+ maxNum = maxVal.toNumber();
882
+ } else if (typeof maxVal === 'number') {
883
+ maxNum = maxVal;
884
+ } else {
885
+ continue;
886
+ }
887
+ if (inputNum >= maxNum) continue;
888
+ }
889
+
890
+ const result = rowObj[returnField];
891
+ return result !== undefined ? result : 0;
892
+ }
893
+
894
+ return 0;
895
+ },
896
+ };
897
+
749
898
  // Register all functions
750
899
  const allFunctions = [
751
900
  // Math
@@ -760,6 +909,8 @@ export function createBuiltInFunctions(decimalUtils: DecimalUtils): Map<string,
760
909
  NUMBER, STRING, BOOLEAN, TYPEOF,
761
910
  // Array
762
911
  FIRST, LAST, REVERSE, SLICE, INCLUDES, INDEXOF, FLATTEN,
912
+ // Table/Lookup
913
+ LOOKUP, RANGE,
763
914
  ];
764
915
 
765
916
  for (const fn of allFunctions) {
package/src/index.ts CHANGED
@@ -35,6 +35,8 @@ export {
35
35
  BooleanLiteral,
36
36
  NullLiteral,
37
37
  ArrayLiteral,
38
+ ObjectLiteral,
39
+ ObjectLiteralProperty,
38
40
  VariableReference,
39
41
  BinaryOperation,
40
42
  UnaryOperation,