├── .gitignore ├── .rubocop.yml ├── .travis.yml ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── bin └── test ├── gemfiles ├── rails_61.gemfile ├── rails_61.gemfile.lock ├── rails_70.gemfile └── rails_70.gemfile.lock ├── lib ├── procore-sift.rb ├── sift │ ├── filter.rb │ ├── filter_validator.rb │ ├── filtrator.rb │ ├── parameter.rb │ ├── scope_handler.rb │ ├── sort.rb │ ├── subset_comparator.rb │ ├── type_validator.rb │ ├── validators │ │ ├── valid_date_range_validator.rb │ │ ├── valid_int_validator.rb │ │ └── valid_json_validator.rb │ ├── value_parser.rb │ ├── version.rb │ └── where_handler.rb └── tasks │ └── filterable_tasks.rake ├── sift.gemspec └── test ├── controller_inheritance_test.rb ├── controller_test.rb ├── dummy ├── Rakefile ├── app │ ├── assets │ │ ├── config │ │ │ └── manifest.js │ │ ├── images │ │ │ └── .keep │ │ ├── javascripts │ │ │ ├── application.js │ │ │ ├── cable.js │ │ │ ├── channels │ │ │ │ └── .keep │ │ │ └── posts.js │ │ └── stylesheets │ │ │ ├── application.css │ │ │ └── posts.css │ ├── channels │ │ └── application_cable │ │ │ ├── channel.rb │ │ │ └── connection.rb │ ├── controllers │ │ ├── application_controller.rb │ │ ├── concerns │ │ │ └── .keep │ │ ├── posts_alt_controller.rb │ │ └── posts_controller.rb │ ├── helpers │ │ ├── application_helper.rb │ │ └── posts_helper.rb │ ├── jobs │ │ └── application_job.rb │ ├── mailers │ │ └── application_mailer.rb │ ├── models │ │ ├── application_record.rb │ │ ├── concerns │ │ │ └── .keep │ │ └── post.rb │ └── views │ │ └── layouts │ │ ├── application.html.erb │ │ ├── mailer.html.erb │ │ └── mailer.text.erb ├── bin │ ├── bundle │ ├── rails │ ├── rake │ ├── setup │ ├── update │ └── yarn ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── cable.yml │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── application_controller_renderer.rb │ │ ├── backtrace_silencers.rb │ │ ├── cookies_serializer.rb │ │ ├── filter_parameter_logging.rb │ │ ├── inflections.rb │ │ ├── mime_types.rb │ │ ├── session_store.rb │ │ └── wrap_parameters.rb │ ├── locales │ │ └── en.yml │ ├── puma.rb │ ├── routes.rb │ ├── secrets.yml │ └── spring.rb ├── db │ ├── migrate │ │ ├── 20160909014304_create_posts.rb │ │ └── 20200512151604_add_metadata_to_posts.rb │ └── schema.rb ├── lib │ └── assets │ │ └── .keep ├── log │ └── .keep ├── public │ ├── 404.html │ ├── 422.html │ ├── 500.html │ ├── apple-touch-icon-precomposed.png │ ├── apple-touch-icon.png │ └── favicon.ico └── test │ ├── controllers │ └── posts_controller_test.rb │ ├── fixtures │ └── posts.yml │ └── models │ └── post_test.rb ├── filter_test.rb ├── filter_validator_test.rb ├── filtrator_test.rb ├── sift_test.rb ├── sort_test.rb ├── test_helper.rb ├── type_validator_test.rb └── value_parser_test.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | log/*.log 3 | *.swp 4 | Gemfile.lock 5 | pkg/ 6 | test/dummy/db/*.sqlite3 7 | test/dummy/db/*.sqlite3-journal 8 | test/dummy/log/*.log 9 | test/dummy/tmp/ 10 | coverage/ 11 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.3 3 | Include: 4 | - '**/Rakefile' 5 | - '**/config.ru' 6 | Exclude: 7 | - 'db/**/*' 8 | - 'config/**/*' 9 | - 'script/**/*' 10 | - 'vendor/bundle/**/*' 11 | - 'node_modules/**/*' 12 | - 'test/dummy/bin/**/*' 13 | 14 | Naming/AccessorMethodName: 15 | Description: Check the naming of accessor methods for get_/set_. 16 | Enabled: false 17 | 18 | Naming/MemoizedInstanceVariableName: 19 | Enabled: false 20 | 21 | # Waiting for https://github.com/bbatsov/rubocop/pull/5230 22 | Style/FormatStringToken: 23 | Enabled: false 24 | 25 | Style/Alias: 26 | Description: 'Use alias_method instead of alias.' 27 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#alias-method' 28 | Enabled: false 29 | 30 | Style/ArrayJoin: 31 | Description: 'Use Array#join instead of Array#*.' 32 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#array-join' 33 | Enabled: false 34 | 35 | Style/AsciiComments: 36 | Description: 'Use only ascii symbols in comments.' 37 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#english-comments' 38 | Enabled: false 39 | 40 | Naming/AsciiIdentifiers: 41 | Description: 'Use only ascii symbols in identifiers.' 42 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#english-identifiers' 43 | Enabled: false 44 | 45 | Style/Attr: 46 | Description: 'Checks for uses of Module#attr.' 47 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#attr' 48 | Enabled: false 49 | 50 | Metrics/BlockNesting: 51 | Description: 'Avoid excessive block nesting' 52 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#three-is-the-number-thou-shalt-count' 53 | Enabled: false 54 | 55 | Style/CaseEquality: 56 | Description: 'Avoid explicit use of the case equality operator(===).' 57 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-case-equality' 58 | Enabled: false 59 | 60 | Style/CharacterLiteral: 61 | Description: 'Checks for uses of character literals.' 62 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-character-literals' 63 | Enabled: false 64 | 65 | Style/ClassAndModuleChildren: 66 | Description: 'Checks style of children classes and modules.' 67 | Enabled: true 68 | EnforcedStyle: nested 69 | Exclude: 70 | - 'test/**/*' 71 | 72 | Style/TrailingCommaInHashLiteral: 73 | Enabled: false 74 | 75 | Metrics/ClassLength: 76 | Description: 'Avoid classes longer than 100 lines of code.' 77 | Enabled: false 78 | 79 | Metrics/ModuleLength: 80 | Description: 'Avoid modules longer than 100 lines of code.' 81 | Enabled: false 82 | 83 | Style/ClassVars: 84 | Description: 'Avoid the use of class variables.' 85 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-class-vars' 86 | Enabled: false 87 | 88 | Style/CollectionMethods: 89 | Enabled: true 90 | PreferredMethods: 91 | find: detect 92 | inject: reduce 93 | collect: map 94 | find_all: select 95 | 96 | Style/ColonMethodCall: 97 | Description: 'Do not use :: for method call.' 98 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#double-colons' 99 | Enabled: false 100 | 101 | Style/CommentAnnotation: 102 | Description: >- 103 | Checks formatting of special comments 104 | (TODO, FIXME, OPTIMIZE, HACK, REVIEW). 105 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#annotate-keywords' 106 | Enabled: false 107 | 108 | Metrics/AbcSize: 109 | Description: >- 110 | A calculated magnitude based on number of assignments, 111 | branches, and conditions. 112 | Enabled: false 113 | 114 | Metrics/BlockLength: 115 | Enabled: false 116 | CountComments: true # count full line comments? 117 | Max: 25 118 | ExcludedMethods: [] 119 | Exclude: 120 | - "**/*.rake" 121 | - "spec/**/*" 122 | 123 | Metrics/CyclomaticComplexity: 124 | Description: >- 125 | A complexity metric that is strongly correlated to the number 126 | of test cases needed to validate a method. 127 | Enabled: false 128 | 129 | Rails/Delegate: 130 | Description: 'Prefer delegate method for delegations.' 131 | Enabled: false 132 | 133 | Style/PreferredHashMethods: 134 | Description: 'Checks use of `has_key?` and `has_value?` Hash methods.' 135 | StyleGuide: '#hash-key' 136 | Enabled: false 137 | 138 | Style/Documentation: 139 | Description: 'Document classes and non-namespace modules.' 140 | Enabled: false 141 | 142 | Style/DoubleNegation: 143 | Description: 'Checks for uses of double negation (!!).' 144 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-bang-bang' 145 | Enabled: false 146 | 147 | Style/EachWithObject: 148 | Description: 'Prefer `each_with_object` over `inject` or `reduce`.' 149 | Enabled: false 150 | 151 | Style/EmptyLiteral: 152 | Description: 'Prefer literals to Array.new/Hash.new/String.new.' 153 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#literal-array-hash' 154 | Enabled: false 155 | 156 | # Checks whether the source file has a utf-8 encoding comment or not 157 | # AutoCorrectEncodingComment must match the regex 158 | # /#.*coding\s?[:=]\s?(?:UTF|utf)-8/ 159 | Style/Encoding: 160 | Enabled: false 161 | 162 | Style/EvenOdd: 163 | Description: 'Favor the use of Fixnum#even? && Fixnum#odd?' 164 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#predicate-methods' 165 | Enabled: false 166 | 167 | Naming/FileName: 168 | Description: 'Use snake_case for source file names.' 169 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#snake-case-files' 170 | Enabled: false 171 | 172 | Style/FrozenStringLiteralComment: 173 | Description: >- 174 | Add the frozen_string_literal comment to the top of files 175 | to help transition from Ruby 2.3.0 to Ruby 3.0. 176 | Enabled: false 177 | 178 | Lint/FlipFlop: 179 | Description: 'Checks for flip flops' 180 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-flip-flops' 181 | Enabled: false 182 | 183 | Style/FormatString: 184 | Description: 'Enforce the use of Kernel#sprintf, Kernel#format or String#%.' 185 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#sprintf' 186 | Enabled: false 187 | 188 | Style/GlobalVars: 189 | Description: 'Do not introduce global variables.' 190 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#instance-vars' 191 | Reference: 'http://www.zenspider.com/Languages/Ruby/QuickRef.html' 192 | Enabled: false 193 | 194 | Style/GuardClause: 195 | Description: 'Check for conditionals that can be replaced with guard clauses' 196 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-nested-conditionals' 197 | Enabled: false 198 | 199 | Style/IfUnlessModifier: 200 | Description: >- 201 | Favor modifier if/unless usage when you have a 202 | single-line body. 203 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#if-as-a-modifier' 204 | Enabled: false 205 | 206 | Style/IfWithSemicolon: 207 | Description: 'Do not use if x; .... Use the ternary operator instead.' 208 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-semicolon-ifs' 209 | Enabled: false 210 | 211 | Style/InlineComment: 212 | Description: 'Avoid inline comments.' 213 | Enabled: false 214 | 215 | Style/Lambda: 216 | Description: 'Use the new lambda literal syntax for single-line blocks.' 217 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#lambda-multi-line' 218 | Enabled: false 219 | 220 | Style/LambdaCall: 221 | Description: 'Use lambda.call(...) instead of lambda.(...).' 222 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#proc-call' 223 | Enabled: false 224 | 225 | Style/LineEndConcatenation: 226 | Description: >- 227 | Use \ instead of + or << to concatenate two string literals at 228 | line end. 229 | Enabled: false 230 | 231 | Layout/LineLength: 232 | Description: 'Limit lines to 80 characters.' 233 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#80-character-limits' 234 | Max: 150 235 | 236 | Metrics/MethodLength: 237 | Description: 'Avoid methods longer than 10 lines of code.' 238 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#short-methods' 239 | Enabled: false 240 | 241 | Style/ModuleFunction: 242 | Description: 'Checks for usage of `extend self` in modules.' 243 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#module-function' 244 | Enabled: false 245 | 246 | Style/MultilineBlockChain: 247 | Description: 'Avoid multi-line chains of blocks.' 248 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#single-line-blocks' 249 | Enabled: false 250 | 251 | Style/NegatedIf: 252 | Description: >- 253 | Favor unless over if for negative conditions 254 | (or control flow or). 255 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#unless-for-negatives' 256 | Enabled: false 257 | 258 | Style/NegatedWhile: 259 | Description: 'Favor until over while for negative conditions.' 260 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#until-for-negatives' 261 | Enabled: false 262 | 263 | Style/Next: 264 | Description: 'Use `next` to skip iteration instead of a condition at the end.' 265 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-nested-conditionals' 266 | Enabled: false 267 | 268 | Style/NilComparison: 269 | Description: 'Prefer x.nil? to x == nil.' 270 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#predicate-methods' 271 | Enabled: false 272 | 273 | Style/Not: 274 | Description: 'Use ! instead of not.' 275 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#bang-not-not' 276 | Enabled: false 277 | 278 | Style/NumericLiterals: 279 | Description: >- 280 | Add underscores to large numeric literals to improve their 281 | readability. 282 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#underscores-in-numerics' 283 | Enabled: false 284 | 285 | Style/OneLineConditional: 286 | Description: >- 287 | Favor the ternary operator(?:) over 288 | if/then/else/end constructs. 289 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#ternary-operator' 290 | Enabled: false 291 | 292 | Naming/BinaryOperatorParameterName: 293 | Description: 'When defining binary operators, name the argument other.' 294 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#other-arg' 295 | Enabled: false 296 | 297 | Metrics/ParameterLists: 298 | Description: 'Avoid parameter lists longer than three or four parameters.' 299 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#too-many-params' 300 | Enabled: false 301 | 302 | Style/PercentLiteralDelimiters: 303 | Description: 'Use `%`-literal delimiters consistently' 304 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#percent-literal-braces' 305 | Enabled: false 306 | 307 | Style/PerlBackrefs: 308 | Description: 'Avoid Perl-style regex back references.' 309 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-perl-regexp-last-matchers' 310 | Enabled: false 311 | 312 | Naming/PredicateName: 313 | Description: 'Check the names of predicate methods.' 314 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#bool-methods-qmark' 315 | NamePrefixBlacklist: 316 | - is_ 317 | Exclude: 318 | - spec/**/* 319 | 320 | Style/Proc: 321 | Description: 'Use proc instead of Proc.new.' 322 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#proc' 323 | Enabled: false 324 | 325 | Style/RaiseArgs: 326 | Description: 'Checks the arguments passed to raise/fail.' 327 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#exception-class-messages' 328 | Enabled: false 329 | 330 | Style/RegexpLiteral: 331 | Description: 'Use / or %r around regular expressions.' 332 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#percent-r' 333 | Enabled: false 334 | 335 | Style/SelfAssignment: 336 | Description: >- 337 | Checks for places where self-assignment shorthand should have 338 | been used. 339 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#self-assignment' 340 | Enabled: false 341 | 342 | Style/SingleLineBlockParams: 343 | Description: 'Enforces the names of some block params.' 344 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#reduce-blocks' 345 | Enabled: false 346 | 347 | Style/SingleLineMethods: 348 | Description: 'Avoid single-line methods.' 349 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-single-line-methods' 350 | Enabled: false 351 | 352 | Style/EmptyMethod: 353 | Enabled: true 354 | EnforcedStyle: expanded 355 | 356 | Style/SignalException: 357 | Description: 'Checks for proper usage of fail and raise.' 358 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#fail-method' 359 | Enabled: false 360 | 361 | Style/SpecialGlobalVars: 362 | Description: 'Avoid Perl-style global variables.' 363 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-cryptic-perlisms' 364 | Enabled: false 365 | 366 | Style/StringLiterals: 367 | Description: 'Checks if uses of quotes match the configured preference.' 368 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#consistent-string-literals' 369 | EnforcedStyle: double_quotes 370 | Enabled: true 371 | 372 | Style/TrailingCommaInArguments: 373 | Description: 'Checks for trailing comma in argument lists.' 374 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-trailing-array-commas' 375 | EnforcedStyleForMultiline: comma 376 | SupportedStylesForMultiline: 377 | - comma 378 | - consistent_comma 379 | - no_comma 380 | Enabled: true 381 | 382 | Style/TrailingCommaInArrayLiteral: 383 | Description: 'Checks for trailing comma in array and hash literals.' 384 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-trailing-array-commas' 385 | EnforcedStyleForMultiline: comma 386 | SupportedStylesForMultiline: 387 | - comma 388 | - consistent_comma 389 | - no_comma 390 | Enabled: true 391 | 392 | Style/TrivialAccessors: 393 | Description: 'Prefer attr_* methods to trivial readers/writers.' 394 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#attr_family' 395 | Enabled: false 396 | 397 | Style/VariableInterpolation: 398 | Description: >- 399 | Don't interpolate global, instance and class variables 400 | directly in strings. 401 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#curlies-interpolate' 402 | Enabled: false 403 | 404 | Style/WhenThen: 405 | Description: 'Use when x then ... for one-line cases.' 406 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#one-line-cases' 407 | Enabled: false 408 | 409 | Style/WhileUntilModifier: 410 | Description: >- 411 | Favor modifier while/until usage when you have a 412 | single-line body. 413 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#while-as-a-modifier' 414 | Enabled: false 415 | 416 | Style/WordArray: 417 | Description: 'Use %w or %W for arrays of words.' 418 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#percent-w' 419 | Enabled: true 420 | EnforcedStyle: brackets 421 | 422 | Style/SymbolArray: 423 | Enabled: true 424 | EnforcedStyle: brackets 425 | 426 | Style/AndOr: 427 | # Whether `and` and `or` are banned only in conditionals (conditionals) 428 | # or completely (always). 429 | EnforcedStyle: always 430 | SupportedStyles: 431 | - always 432 | - conditionals 433 | Exclude: 434 | - '**/app/controllers/**/*' 435 | 436 | Style/RedundantBegin: 437 | Enabled: false 438 | 439 | # Layout 440 | 441 | Layout/ParameterAlignment: 442 | Description: 'Here we check if the parameters on a multi-line method call or definition are aligned.' 443 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-double-indent' 444 | Enabled: false 445 | 446 | Layout/DotPosition: 447 | Description: 'Checks the position of the dot in multi-line method calls.' 448 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#consistent-multi-line-chains' 449 | EnforcedStyle: leading 450 | 451 | Layout/ExtraSpacing: 452 | Description: 'Do not use unnecessary spacing.' 453 | Enabled: true 454 | 455 | Layout/MultilineOperationIndentation: 456 | Description: >- 457 | Checks indentation of binary operations that span more than 458 | one line. 459 | Enabled: true 460 | EnforcedStyle: indented 461 | 462 | Layout/MultilineMethodCallIndentation: 463 | Description: >- 464 | Checks indentation of method calls with the dot operator 465 | that span more than one line. 466 | Enabled: true 467 | EnforcedStyle: indented 468 | 469 | Layout/InitialIndentation: 470 | Description: >- 471 | Checks the indentation of the first non-blank non-comment line in a file. 472 | Enabled: false 473 | 474 | # Lint 475 | 476 | Lint/AmbiguousOperator: 477 | Description: >- 478 | Checks for ambiguous operators in the first argument of a 479 | method invocation without parentheses. 480 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#parens-as-args' 481 | Enabled: false 482 | 483 | Lint/AmbiguousRegexpLiteral: 484 | Description: >- 485 | Checks for ambiguous regexp literals in the first argument of 486 | a method invocation without parenthesis. 487 | Enabled: false 488 | 489 | Lint/AssignmentInCondition: 490 | Description: "Don't use assignment in conditions." 491 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#safe-assignment-in-condition' 492 | Enabled: false 493 | 494 | Lint/CircularArgumentReference: 495 | Description: "Don't refer to the keyword argument in the default value." 496 | Enabled: false 497 | 498 | Layout/ConditionPosition: 499 | Description: >- 500 | Checks for condition placed in a confusing position relative to 501 | the keyword. 502 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#same-line-condition' 503 | Enabled: false 504 | 505 | Lint/DeprecatedClassMethods: 506 | Description: 'Check for deprecated class method calls.' 507 | Enabled: false 508 | 509 | Lint/DuplicatedHashKey: 510 | Description: 'Check for duplicate keys in hash literals.' 511 | Enabled: false 512 | 513 | Lint/EachWithObjectArgument: 514 | Description: 'Check for immutable argument given to each_with_object.' 515 | Enabled: false 516 | 517 | Lint/ElseLayout: 518 | Description: 'Check for odd code arrangement in an else block.' 519 | Enabled: false 520 | 521 | Lint/FormatParameterMismatch: 522 | Description: 'The number of parameters to format/sprint must match the fields.' 523 | Enabled: false 524 | 525 | Lint/SuppressedException: 526 | Description: "Don't suppress exception." 527 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#dont-hide-exceptions' 528 | Enabled: false 529 | 530 | Lint/LiteralInInterpolation: 531 | Description: 'Checks for literals used in interpolation.' 532 | Enabled: false 533 | 534 | Lint/Loop: 535 | Description: >- 536 | Use Kernel#loop with break rather than begin/end/until or 537 | begin/end/while for post-loop tests. 538 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#loop-with-break' 539 | Enabled: false 540 | 541 | Lint/NestedMethodDefinition: 542 | Description: 'Do not use nested method definitions.' 543 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-nested-methods' 544 | Enabled: false 545 | 546 | Lint/NonLocalExitFromIterator: 547 | Description: 'Do not use return in iterator to cause non-local exit.' 548 | Enabled: false 549 | 550 | Lint/ParenthesesAsGroupedExpression: 551 | Description: >- 552 | Checks for method calls with a space before the opening 553 | parenthesis. 554 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#parens-no-spaces' 555 | Enabled: false 556 | 557 | Lint/RequireParentheses: 558 | Description: >- 559 | Use parentheses in the method call to avoid confusion 560 | about precedence. 561 | Enabled: false 562 | 563 | Lint/UnderscorePrefixedVariableName: 564 | Description: 'Do not use prefix `_` for a variable that is used.' 565 | Enabled: false 566 | 567 | Lint/Void: 568 | Description: 'Possible use of operator/literal/variable in void context.' 569 | Enabled: false 570 | 571 | # Performance 572 | 573 | Performance/CaseWhenSplat: 574 | Description: >- 575 | Place `when` conditions that use splat at the end 576 | of the list of `when` branches. 577 | Enabled: false 578 | 579 | Performance/Count: 580 | Description: >- 581 | Use `count` instead of `select...size`, `reject...size`, 582 | `select...count`, `reject...count`, `select...length`, 583 | and `reject...length`. 584 | Enabled: false 585 | 586 | Performance/Detect: 587 | Description: >- 588 | Use `detect` instead of `select.first`, `find_all.first`, 589 | `select.last`, and `find_all.last`. 590 | Reference: 'https://github.com/JuanitoFatas/fast-ruby#enumerabledetect-vs-enumerableselectfirst-code' 591 | Enabled: false 592 | 593 | Performance/FlatMap: 594 | Description: >- 595 | Use `Enumerable#flat_map` 596 | instead of `Enumerable#map...Array#flatten(1)` 597 | or `Enumberable#collect..Array#flatten(1)` 598 | Reference: 'https://github.com/JuanitoFatas/fast-ruby#enumerablemaparrayflatten-vs-enumerableflat_map-code' 599 | Enabled: false 600 | 601 | Performance/ReverseEach: 602 | Description: 'Use `reverse_each` instead of `reverse.each`.' 603 | Reference: 'https://github.com/JuanitoFatas/fast-ruby#enumerablereverseeach-vs-enumerablereverse_each-code' 604 | Enabled: false 605 | 606 | Style/Sample: 607 | Description: >- 608 | Use `sample` instead of `shuffle.first`, 609 | `shuffle.last`, and `shuffle[Fixnum]`. 610 | Reference: 'https://github.com/JuanitoFatas/fast-ruby#arrayshufflefirst-vs-arraysample-code' 611 | Enabled: false 612 | 613 | Performance/Size: 614 | Description: >- 615 | Use `size` instead of `count` for counting 616 | the number of elements in `Array` and `Hash`. 617 | Reference: 'https://github.com/JuanitoFatas/fast-ruby#arraycount-vs-arraysize-code' 618 | Enabled: false 619 | 620 | Performance/StringReplacement: 621 | Description: >- 622 | Use `tr` instead of `gsub` when you are replacing the same 623 | number of characters. Use `delete` instead of `gsub` when 624 | you are deleting characters. 625 | Reference: 'https://github.com/JuanitoFatas/fast-ruby#stringgsub-vs-stringtr-code' 626 | Enabled: false 627 | 628 | # Rails 629 | 630 | Rails/ActionFilter: 631 | Description: 'Enforces consistent use of action filter methods.' 632 | Enabled: false 633 | 634 | Rails/Date: 635 | Description: >- 636 | Checks the correct usage of date aware methods, 637 | such as Date.today, Date.current etc. 638 | Enabled: false 639 | 640 | Rails/FindBy: 641 | Description: 'Prefer find_by over where.first.' 642 | Enabled: false 643 | 644 | Rails/FindEach: 645 | Description: 'Prefer all.find_each over all.find.' 646 | Enabled: false 647 | 648 | Rails/HasAndBelongsToMany: 649 | Description: 'Prefer has_many :through to has_and_belongs_to_many.' 650 | Enabled: false 651 | 652 | Rails/Output: 653 | Description: 'Checks for calls to puts, print, etc.' 654 | Enabled: false 655 | 656 | Rails/ReadWriteAttribute: 657 | Description: >- 658 | Checks for read_attribute(:attr) and 659 | write_attribute(:attr, val). 660 | Enabled: false 661 | 662 | Rails/ScopeArgs: 663 | Description: 'Checks the arguments of ActiveRecord scopes.' 664 | Enabled: false 665 | 666 | Rails/TimeZone: 667 | Description: 'Checks the correct usage of time zone aware methods.' 668 | StyleGuide: 'https://github.com/bbatsov/rails-style-guide#time' 669 | Reference: 'http://danilenko.org/2012/7/6/rails_timezones' 670 | Enabled: false 671 | 672 | Rails/Validation: 673 | Description: 'Use validates :attribute, hash of validations.' 674 | Enabled: false 675 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.3 4 | - 2.4 5 | - 2.5 6 | 7 | cache: bundler 8 | 9 | before_install: 10 | - gem update --system --no-document 11 | - gem install rake 12 | - gem install bundler 13 | 14 | script: bundle exec rake 15 | 16 | deploy: 17 | provider: rubygems 18 | api_key: 19 | secure: pReiutaKz4gErUDrBoLt/a4oMC+KjXBgsUW6uVhdlsvBQ1IYiUjWoV09dBrxlsCsE9Pp0f5vwShKXIe5rUY1AGwXTWxyObNNBffRIz2YrolOJieG8vwEJ7v8sy2GGaxT/mVa1KK0HvNHXI/rpGA+Z3Qg5HqKpvDjrB36CKZgzU9CWrK1+wncuoatHDj5ZDsseXCWaEmQxQjHsYML4Wh2mhfjlthMXuMxAegDb9iY2VydnNZuQUd8An2NJF6BGFOKQySwM2gtrpNO6rhjlMBhIVdZkFx4g2lFJGuyykon0HOrj1jBtJtoHrUWLcu959pfZcqLDO1ut0ZVOXjaxouNu8Hf9+qCXzKwfAAmevkCD3u8LYg0W3MFkBadseyxCUYgInFxBib8Qw4JaVPrF0ccoaOBZYsY77MB+KEx830F/Ag2JLjVJ0CugJ9idzah/vjZLcyNkSi9QBcYeQzbtAU3jMsC0P4yrRYbx4z1eAWHLOPHjr87L8GZY12DXzbjV/Sp9EjpEq4DGBVQRLPkEjvdJiju6e3JzMHAb3CkDtduhdAmYTdUr8qWNSE2da4Hu3+8fRGQVkGdFkJM/WCOuEKBlC041KGGVO4KSJfMBfl4ZX2SmZoc0KC3qNPKK/l2UmF+b5PPXqIpGa0tsCmrXHfevmpJPgwMnvSWdOztxeBlNoU= 20 | gem: chemex 21 | on: 22 | tags: true 23 | repo: procore/chemex 24 | 25 | notifications: 26 | email: false 27 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise 'rails-61' do 2 | gem 'rails', '~> 6.1' 3 | end 4 | 5 | appraise 'rails-70' do 6 | gem 'rails', '~> 7.0.0' 7 | end -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Unreleased 2 | 3 | ## 1.0.0 4 | 5 | - Bump version to 1.0.0, making it an official release 6 | - Change dependencies to only include `activerecord` as a direct dependency instead of the whole Rails framework 7 | ### Breaking changes: 8 | - Bump required Ruby version to 2.7 9 | - Drop support for Rails/ActiveRecord 4 and 5 10 | - Require `activerecord >= 6.1` 11 | 12 | ## 0.17.0 13 | 14 | - Add support for Rails 7.0 15 | 16 | ## 0.16.0 17 | 18 | - Adds a `tap` method to `filter_on` to support mutating filter values 19 | 20 | ## 0.15.0 21 | 22 | - Support for `null` filtering by `jsonb` type 23 | 24 | ## 0.14.0 25 | 26 | - Add support for `jsonb` type (only for PostgreSQL) 27 | 28 | ## 0.13.0 29 | 30 | ## 0.12.0 31 | 32 | - Change gem name to procore-sift 33 | 34 | ## 0.11.0 35 | 36 | - Rename gem to Sift 37 | - Add normalization and validation for date range values 38 | - Tightened up ValueParser by privatizing unnecessarily public attr_accessors 39 | 40 | ## 0.10.0 41 | 42 | - Support for integer filtering of JSON arrays 43 | 44 | ## 0.9.2 (January 26, 2018) 45 | 46 | - Rename gem to Brita 47 | - Publish to RubyGems 48 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Declare your gem's dependencies in sift.gemspec. 4 | # Bundler will treat runtime dependencies like base dependencies, and 5 | # development dependencies will be added by default to the :development group. 6 | gemspec 7 | 8 | # Declare any dependencies that are still in development here instead of in 9 | # your gemspec. These might include edge Rails or gems from your path or 10 | # Git. Remember to move these dependencies to your gemspec before releasing 11 | # your gem to rubygems.org. 12 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Adam Hess 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sift 2 | 3 | [![Build Status](https://travis-ci.org/procore/sift.svg?branch=master)](https://travis-ci.org/procore/sift) 4 | 5 | A tool to build your own filters and sorts with Rails and Active Record! 6 | 7 | ## Developer Usage 8 | 9 | Include Sift in your controllers, and define some filters. 10 | 11 | ```ruby 12 | class PostsController < ApplicationController 13 | include Sift 14 | 15 | filter_on :title, type: :string 16 | 17 | def index 18 | render json: filtrate(Post.all) 19 | end 20 | end 21 | ``` 22 | 23 | This will allow users to pass `?filters[title]=foo` and get the `Post`s with the title `foo`. 24 | 25 | Sift will also handle rendering errors using the standard rails errors structure. You can add this to your controller by adding, 26 | 27 | ```ruby 28 | before_action :render_filter_errors, unless: :filters_valid? 29 | 30 | def render_filter_errors 31 | render json: { errors: filter_errors }, status: :bad_request && return 32 | end 33 | ``` 34 | 35 | to your controller. 36 | 37 | These errors are based on the type that you told sift your param was. 38 | 39 | ### Filter Types 40 | 41 | Every filter must have a type, so that Sift knows what to do with it. The current valid filter types are: 42 | 43 | - int - Filter on an integer column 44 | - decimal - Filter on a decimal column 45 | - boolean - Filter on a boolean column 46 | - string - Filter on a string column 47 | - text - Filter on a text column 48 | - date - Filter on a date column 49 | - time - Filter on a time column 50 | - datetime - Filter on a datetime column 51 | - scope - Filter on an ActiveRecord scope 52 | - jsonb - Filter on a jsonb column (supported only in PostgreSQL) 53 | 54 | ### Filter on Scopes 55 | 56 | Just as your filter values are used to scope queries on a column, values you 57 | pass to a scope filter will be used as arguments to that scope. For example: 58 | 59 | ```ruby 60 | class Post < ActiveRecord::Base 61 | scope :with_body, ->(text) { where(body: text) } 62 | end 63 | 64 | class PostsController < ApplicationController 65 | include Sift 66 | 67 | filter_on :with_body, type: :scope 68 | 69 | def index 70 | render json: filtrate(Post.all) 71 | end 72 | end 73 | ``` 74 | 75 | Passing `?filters[with_body]=my_text` will call the `with_body` scope with 76 | `my_text` as the argument. 77 | 78 | Scopes that accept no arguments are currently not supported. 79 | 80 | #### Accessing Params with Filter Scopes 81 | 82 | Filters with `type: :scope` have access to the params hash by passing in the desired keys to the `scope_params`. The keys passed in will be returned as a hash with their associated values. 83 | 84 | ```ruby 85 | class Post < ActiveRecord::Base 86 | scope :user_posts_on_date, ->(date, options) { 87 | where(user_id: options[:user_id], blog_id: options[:blog_id], date: date) 88 | } 89 | end 90 | 91 | class UsersController < ApplicationController 92 | include Sift 93 | 94 | filter_on :user_posts_on_date, type: :scope, scope_params: [:user_id, :blog_id] 95 | 96 | def show 97 | render json: filtrate(Post.all) 98 | end 99 | end 100 | ``` 101 | 102 | Passing `?filters[user_posts_on_date]=10/12/20` will call the `user_posts_on_date` scope with 103 | `10/12/20` as the the first argument, and will grab the `user_id` and `blog_id` out of the params and pass them as a hash, as the second argument. 104 | 105 | ### Renaming Filter Params 106 | 107 | A filter param can have a different field name than the column or scope. Use `internal_name` with the correct name of the column or scope. 108 | 109 | ```ruby 110 | class PostsController < ApplicationController 111 | include Sift 112 | 113 | filter_on :post_id, type: :int, internal_name: :id 114 | 115 | end 116 | ``` 117 | 118 | ### Filter on Ranges 119 | 120 | Some parameter types support ranges. Ranges are expected to be a string with the bounding values separated by `...` 121 | 122 | For example `?filters[price]=3...50` would return records with a price between 3 and 50. 123 | 124 | The following types support ranges 125 | 126 | - int 127 | - decimal 128 | - boolean 129 | - date 130 | - time 131 | - datetime 132 | 133 | ### Mutating Filters 134 | 135 | Filters can be mutated before the filter is applied using the `tap` argument. This is useful, for example, if you need to adjust the time zone of a `datetime` range filter. 136 | 137 | ```ruby 138 | 139 | class PostsController < ApplicationController 140 | include Sift 141 | 142 | filter_on :expiration, type: :datetime, tap: ->(value, params) { 143 | value.split("..."). 144 | map do |str| 145 | str.to_date.in_time_zone(LOCAL_TIME_ZONE) 146 | end. 147 | join("...") 148 | } 149 | end 150 | ``` 151 | 152 | ### Filter on jsonb column 153 | 154 | Usually JSONB columns stores values as an Array or an Object (key-value), in both cases the parameter needs to be sent in a JSON format 155 | 156 | **Array** 157 | 158 | It should be sent an array in the URL Query parameters 159 | 160 | - `?filters[metadata]=[1,2]` 161 | 162 | **key-value** 163 | 164 | It can be passed one or more Key values: 165 | 166 | - `?filters[metadata]={"data_1":"test"}` 167 | - `?filters[metadata]={"data_1":"test","data_2":"[1,2]"}` 168 | 169 | When the value is an array, it will filter records with those values or more, for example: 170 | 171 | - `?filters[metadata]={"data_2":"[1,2]"}` 172 | 173 | Will return records with next values stored in the JSONB column `metadata`: 174 | 175 | ```ruby 176 | { data_2: 1 } 177 | { data_2: 2 } 178 | { data_2: [1] } 179 | { data_2: [2] } 180 | { data_2: [1,2] } 181 | { data_2: [1,2,3] } 182 | ``` 183 | 184 | When the `null` value is included in the array, it will return also all the records without any value in that property, for example: 185 | 186 | - `?filters[metadata]={"data_2":"[false,null]"}` 187 | 188 | Will return records with next values stored in the JSONB column `metadata`: 189 | 190 | ```ruby 191 | { data_2: null } 192 | { data_2: false } 193 | { data_2: [false] } 194 | { data_1: {another: 'information'} } # When the JSONB key "data_2" is not set. 195 | ``` 196 | 197 | ### Filter on JSON Array 198 | 199 | `int` type filters support sending the values as an array in the URL Query parameters. For example `?filters[id]=[1,2]`. This is a way to keep payloads smaller for GET requests. When URI encoded this will become `filters%5Bid%5D=%5B1,2%5D` which is much smaller the standard format of `filters%5Bid%5D%5B%5D=1&&filters%5Bid%5D%5B%5D=2`. 200 | 201 | On the server side, the params will be received as: 202 | 203 | ```ruby 204 | # JSON array encoded result 205 | "filters"=>{"id"=>"[1,2]"} 206 | 207 | # standard array format 208 | "filters"=>{"id"=>["1", "2"]} 209 | ``` 210 | 211 | Note that this feature cannot currently be wrapped in an array and should not be used in combination with sending array parameters individually. 212 | 213 | - `?filters[id][]=[1,2]` => invalid 214 | - `?filters[id][]=[1,2]&filters[id][]=3` => invalid 215 | - `?filters[id]=[1,2]&filters[id]=3` => valid but only 3 is passed through to the server 216 | - `?filters[id]=[1,2]` => valid 217 | 218 | #### A note on encoding for JSON Array feature 219 | 220 | JSON arrays contain the reserved characters "`,`" and "`[`" and "`]`". When encoding a JSON array in the URL there are two different ways to handle the encoding. Both ways are supported by Rails. 221 | For example, lets look at the following filter with a JSON array `?filters[id]=[1,2]`: 222 | 223 | - `?filters%5Bid%5D=%5B1,2%5D` 224 | - `?filters%5Bid%5D%3D%5B1%2C2%5D` 225 | 226 | In both cases Rails will correctly decode to the expected result of 227 | 228 | ```ruby 229 | { "filters" => { "id" => "[1,2]" } } 230 | ``` 231 | 232 | ### Sort Types 233 | 234 | Every sort must have a type, so that Sift knows what to do with it. The current valid sort types are: 235 | 236 | - int - Sort on an integer column 237 | - decimal - Sort on a decimal column 238 | - string - Sort on a string column 239 | - text - Sort on a text column 240 | - date - Sort on a date column 241 | - time - Sort on a time column 242 | - datetime - Sort on a datetime column 243 | - scope - Sort on an ActiveRecord scope 244 | 245 | ### Sort on Scopes 246 | 247 | Just as your sort values are used to scope queries on a column, values you 248 | pass to a scope sort will be used as arguments to that scope. For example: 249 | 250 | ```ruby 251 | class Post < ActiveRecord::Base 252 | scope :order_on_body_no_params, -> { order(body: :asc) } 253 | scope :order_on_body, ->(direction) { order(body: direction) } 254 | scope :order_on_body_then_id, ->(body_direction, id_direction) { order(body: body_direction).order(id: id_direction) } 255 | end 256 | 257 | class PostsController < ApplicationController 258 | include Sift 259 | 260 | sort_on :order_by_body_ascending, internal_name: :order_on_body_no_params, type: :scope 261 | sort_on :order_by_body, internal_name: :order_on_body, type: :scope, scope_params: [:direction] 262 | sort_on :order_by_body_then_id, internal_name: :order_on_body_then_id, type: :scope, scope_params: [:direction, :asc] 263 | 264 | 265 | def index 266 | render json: filtrate(Post.all) 267 | end 268 | end 269 | ``` 270 | 271 | `scope_params` takes an order-specific array of the scope's arguments. Passing in the param :direction allows the consumer to choose which direction to sort in (ex. `-order_by_body` will sort `:desc` while `order_by_body` will sort `:asc`) 272 | 273 | Passing `?sort=-order_by_body` will call the `order_on_body` scope with 274 | `:desc` as the argument. The direction is the only argument that the consumer has control over. 275 | Passing `?sort=-order_by_body_then_id` will call the `order_on_body_then_id` scope where the `body_direction` is `:desc`, and the `id_direction` is `:asc`. Note: in this example the user has no control over id_direction. To demonstrate: 276 | Passing `?sort=order_by_body_then_id` will call the `order_on_body_then_id` scope where the `body_direction` this time is `:asc`, but the `id_direction` remains `:asc`. 277 | 278 | Scopes that accept no arguments are currently supported, but you should note that the user has no say in which direction it will sort on. 279 | 280 | `scope_params` can also accept symbols that are keys in the `params` hash. The value will be fetched and passed on as an argument to the scope. 281 | 282 | ## Consumer Usage 283 | 284 | Filter: 285 | `?filters[]=` 286 | 287 | Filters are translated to Active Record `where`s and are chained together. The order they are applied is not guarenteed. 288 | 289 | Sort: 290 | `?sort=-published_at,position` 291 | 292 | Sort is translated to Active Record `order` The sorts are applied in the order they are passed by the client. 293 | the `-` symbol means to sort in `desc` order. By default, keys are sorted in `asc` order. 294 | 295 | ## Installation 296 | 297 | Add this line to your application's Gemfile: 298 | 299 | ```ruby 300 | gem 'procore-sift' 301 | ``` 302 | 303 | And then execute: 304 | 305 | ```bash 306 | $ bundle 307 | ``` 308 | 309 | Or install it yourself as: 310 | 311 | ```bash 312 | $ gem install procore-sift 313 | ``` 314 | 315 | ## Without Rails 316 | 317 | We have some future plans to remove the rails dependency so that other frameworks such as Sinatra could leverage this gem. 318 | 319 | ## Contributing 320 | 321 | Installing gems before running tests: 322 | 323 | ```bash 324 | $ bundle exec appraisal install 325 | ``` 326 | 327 | Running tests: 328 | 329 | ```bash 330 | $ bundle exec appraisal rake test 331 | ``` 332 | 333 | ## Publishing 334 | 335 | Publishing is done use the `gem` commandline tool. You must have permissions to publish a new version. Users with permissions can be seen here https://rubygems.org/gems/procore-sift. 336 | 337 | When a bump is desired, the gemspec should have the version number bumped and merged into master. 338 | 339 | Step 1: build the new version 340 | `gem build sift.gemspec` 341 | 342 | ``` 343 | Successfully built RubyGem 344 | Name: procore-sift 345 | Version: 0.14.0 346 | File: procore-sift-0.14.0.gem 347 | ``` 348 | 349 | Step2: Push the updated build 350 | `gem push procore-sift-0.14.0.gem` 351 | 352 | ``` 353 | Pushing gem to https://rubygems.org... 354 | Successfully registered gem: procore-sift (0.14.0) 355 | ``` 356 | 357 | ## License 358 | 359 | The gem is available as open source under the terms of the [MIT 360 | License](http://opensource.org/licenses/MIT). 361 | 362 | ## About Procore 363 | 364 | Procore Logo 369 | 370 | The Procore Gem is maintained by Procore Technologies. 371 | 372 | Procore - building the software that builds the world. 373 | 374 | Learn more about the #1 most widely used construction management software at 375 | [procore.com](https://www.procore.com/) 376 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | begin 2 | require "bundler/setup" 3 | rescue LoadError 4 | puts "You must `gem install bundler` and `bundle install` to run rake tasks" 5 | end 6 | 7 | require "bundler/gem_tasks" 8 | 9 | APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) 10 | load "rails/tasks/engine.rake" 11 | 12 | require "rdoc/task" 13 | 14 | RDoc::Task.new(:rdoc) do |rdoc| 15 | rdoc.rdoc_dir = "rdoc" 16 | rdoc.title = "Sift" 17 | rdoc.options << "--line-numbers" 18 | rdoc.rdoc_files.include("README.md") 19 | rdoc.rdoc_files.include("lib/**/*.rb") 20 | end 21 | 22 | require "rake/testtask" 23 | 24 | Rake::TestTask.new(:test) do |t| 25 | t.libs << "lib" 26 | t.libs << "test" 27 | t.pattern = "test/**/*_test.rb" 28 | t.verbose = false 29 | end 30 | 31 | require "rubocop/rake_task" 32 | 33 | RuboCop::RakeTask.new(:rubocop) do |t| 34 | t.options = ["--display-cop-names"] 35 | end 36 | 37 | task default: [:rubocop, :test] 38 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $LOAD_PATH << File.expand_path(File.expand_path("../test", __dir__)) 3 | 4 | require "bundler/setup" 5 | require "rails/test_unit/minitest_plugin" 6 | 7 | exit Minitest.run(ARGV) 8 | -------------------------------------------------------------------------------- /gemfiles/rails_61.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 6.1" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/rails_61.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | procore-sift (1.0.0) 5 | activerecord (>= 6.1) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | actioncable (6.1.7.2) 11 | actionpack (= 6.1.7.2) 12 | activesupport (= 6.1.7.2) 13 | nio4r (~> 2.0) 14 | websocket-driver (>= 0.6.1) 15 | actionmailbox (6.1.7.2) 16 | actionpack (= 6.1.7.2) 17 | activejob (= 6.1.7.2) 18 | activerecord (= 6.1.7.2) 19 | activestorage (= 6.1.7.2) 20 | activesupport (= 6.1.7.2) 21 | mail (>= 2.7.1) 22 | actionmailer (6.1.7.2) 23 | actionpack (= 6.1.7.2) 24 | actionview (= 6.1.7.2) 25 | activejob (= 6.1.7.2) 26 | activesupport (= 6.1.7.2) 27 | mail (~> 2.5, >= 2.5.4) 28 | rails-dom-testing (~> 2.0) 29 | actionpack (6.1.7.2) 30 | actionview (= 6.1.7.2) 31 | activesupport (= 6.1.7.2) 32 | rack (~> 2.0, >= 2.0.9) 33 | rack-test (>= 0.6.3) 34 | rails-dom-testing (~> 2.0) 35 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 36 | actiontext (6.1.7.2) 37 | actionpack (= 6.1.7.2) 38 | activerecord (= 6.1.7.2) 39 | activestorage (= 6.1.7.2) 40 | activesupport (= 6.1.7.2) 41 | nokogiri (>= 1.8.5) 42 | actionview (6.1.7.2) 43 | activesupport (= 6.1.7.2) 44 | builder (~> 3.1) 45 | erubi (~> 1.4) 46 | rails-dom-testing (~> 2.0) 47 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 48 | activejob (6.1.7.2) 49 | activesupport (= 6.1.7.2) 50 | globalid (>= 0.3.6) 51 | activemodel (6.1.7.2) 52 | activesupport (= 6.1.7.2) 53 | activerecord (6.1.7.2) 54 | activemodel (= 6.1.7.2) 55 | activesupport (= 6.1.7.2) 56 | activestorage (6.1.7.2) 57 | actionpack (= 6.1.7.2) 58 | activejob (= 6.1.7.2) 59 | activerecord (= 6.1.7.2) 60 | activesupport (= 6.1.7.2) 61 | marcel (~> 1.0) 62 | mini_mime (>= 1.1.0) 63 | activesupport (6.1.7.2) 64 | concurrent-ruby (~> 1.0, >= 1.0.2) 65 | i18n (>= 1.6, < 2) 66 | minitest (>= 5.1) 67 | tzinfo (~> 2.0) 68 | zeitwerk (~> 2.3) 69 | appraisal (2.4.1) 70 | bundler 71 | rake 72 | thor (>= 0.14.0) 73 | ast (2.4.2) 74 | builder (3.2.4) 75 | coderay (1.1.3) 76 | concurrent-ruby (1.2.2) 77 | crass (1.0.6) 78 | date (3.3.3) 79 | erubi (1.12.0) 80 | globalid (1.1.0) 81 | activesupport (>= 5.0) 82 | i18n (1.12.0) 83 | concurrent-ruby (~> 1.0) 84 | jaro_winkler (1.5.4) 85 | loofah (2.19.1) 86 | crass (~> 1.0.2) 87 | nokogiri (>= 1.5.9) 88 | mail (2.8.1) 89 | mini_mime (>= 0.1.1) 90 | net-imap 91 | net-pop 92 | net-smtp 93 | marcel (1.0.2) 94 | method_source (1.0.0) 95 | mini_mime (1.1.2) 96 | minitest (5.17.0) 97 | net-imap (0.3.4) 98 | date 99 | net-protocol 100 | net-pop (0.1.2) 101 | net-protocol 102 | net-protocol (0.2.1) 103 | timeout 104 | net-smtp (0.3.3) 105 | net-protocol 106 | nio4r (2.5.8) 107 | nokogiri (1.14.2-x86_64-darwin) 108 | racc (~> 1.4) 109 | parallel (1.22.1) 110 | parser (3.1.3.0) 111 | ast (~> 2.4.1) 112 | pry (0.14.1) 113 | coderay (~> 1.1) 114 | method_source (~> 1.0) 115 | racc (1.6.2) 116 | rack (2.2.6.3) 117 | rack-test (2.0.2) 118 | rack (>= 1.3) 119 | rails (6.1.7.2) 120 | actioncable (= 6.1.7.2) 121 | actionmailbox (= 6.1.7.2) 122 | actionmailer (= 6.1.7.2) 123 | actionpack (= 6.1.7.2) 124 | actiontext (= 6.1.7.2) 125 | actionview (= 6.1.7.2) 126 | activejob (= 6.1.7.2) 127 | activemodel (= 6.1.7.2) 128 | activerecord (= 6.1.7.2) 129 | activestorage (= 6.1.7.2) 130 | activesupport (= 6.1.7.2) 131 | bundler (>= 1.15.0) 132 | railties (= 6.1.7.2) 133 | sprockets-rails (>= 2.0.0) 134 | rails-dom-testing (2.0.3) 135 | activesupport (>= 4.2.0) 136 | nokogiri (>= 1.6) 137 | rails-html-sanitizer (1.5.0) 138 | loofah (~> 2.19, >= 2.19.1) 139 | railties (6.1.7.2) 140 | actionpack (= 6.1.7.2) 141 | activesupport (= 6.1.7.2) 142 | method_source 143 | rake (>= 12.2) 144 | thor (~> 1.0) 145 | rainbow (3.1.1) 146 | rake (13.0.6) 147 | rubocop (0.71.0) 148 | jaro_winkler (~> 1.5.1) 149 | parallel (~> 1.10) 150 | parser (>= 2.6) 151 | rainbow (>= 2.2.2, < 4.0) 152 | ruby-progressbar (~> 1.7) 153 | unicode-display_width (>= 1.4.0, < 1.7) 154 | ruby-progressbar (1.11.0) 155 | sprockets (4.2.0) 156 | concurrent-ruby (~> 1.0) 157 | rack (>= 2.2.4, < 4) 158 | sprockets-rails (3.4.2) 159 | actionpack (>= 5.2) 160 | activesupport (>= 5.2) 161 | sprockets (>= 3.0.0) 162 | sqlite3 (1.5.4-x86_64-darwin) 163 | thor (1.2.1) 164 | timeout (0.3.2) 165 | tzinfo (2.0.6) 166 | concurrent-ruby (~> 1.0) 167 | unicode-display_width (1.6.1) 168 | websocket-driver (0.7.5) 169 | websocket-extensions (>= 0.1.0) 170 | websocket-extensions (0.1.5) 171 | zeitwerk (2.6.7) 172 | 173 | PLATFORMS 174 | x86_64-darwin-21 175 | 176 | DEPENDENCIES 177 | appraisal 178 | procore-sift! 179 | pry 180 | rails (~> 6.1) 181 | rake 182 | rubocop (= 0.71.0) 183 | sqlite3 184 | 185 | BUNDLED WITH 186 | 2.3.26 187 | -------------------------------------------------------------------------------- /gemfiles/rails_70.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 7.0.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/rails_70.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | procore-sift (1.0.0) 5 | activerecord (>= 6.1) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | actioncable (7.0.4.2) 11 | actionpack (= 7.0.4.2) 12 | activesupport (= 7.0.4.2) 13 | nio4r (~> 2.0) 14 | websocket-driver (>= 0.6.1) 15 | actionmailbox (7.0.4.2) 16 | actionpack (= 7.0.4.2) 17 | activejob (= 7.0.4.2) 18 | activerecord (= 7.0.4.2) 19 | activestorage (= 7.0.4.2) 20 | activesupport (= 7.0.4.2) 21 | mail (>= 2.7.1) 22 | net-imap 23 | net-pop 24 | net-smtp 25 | actionmailer (7.0.4.2) 26 | actionpack (= 7.0.4.2) 27 | actionview (= 7.0.4.2) 28 | activejob (= 7.0.4.2) 29 | activesupport (= 7.0.4.2) 30 | mail (~> 2.5, >= 2.5.4) 31 | net-imap 32 | net-pop 33 | net-smtp 34 | rails-dom-testing (~> 2.0) 35 | actionpack (7.0.4.2) 36 | actionview (= 7.0.4.2) 37 | activesupport (= 7.0.4.2) 38 | rack (~> 2.0, >= 2.2.0) 39 | rack-test (>= 0.6.3) 40 | rails-dom-testing (~> 2.0) 41 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 42 | actiontext (7.0.4.2) 43 | actionpack (= 7.0.4.2) 44 | activerecord (= 7.0.4.2) 45 | activestorage (= 7.0.4.2) 46 | activesupport (= 7.0.4.2) 47 | globalid (>= 0.6.0) 48 | nokogiri (>= 1.8.5) 49 | actionview (7.0.4.2) 50 | activesupport (= 7.0.4.2) 51 | builder (~> 3.1) 52 | erubi (~> 1.4) 53 | rails-dom-testing (~> 2.0) 54 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 55 | activejob (7.0.4.2) 56 | activesupport (= 7.0.4.2) 57 | globalid (>= 0.3.6) 58 | activemodel (7.0.4.2) 59 | activesupport (= 7.0.4.2) 60 | activerecord (7.0.4.2) 61 | activemodel (= 7.0.4.2) 62 | activesupport (= 7.0.4.2) 63 | activestorage (7.0.4.2) 64 | actionpack (= 7.0.4.2) 65 | activejob (= 7.0.4.2) 66 | activerecord (= 7.0.4.2) 67 | activesupport (= 7.0.4.2) 68 | marcel (~> 1.0) 69 | mini_mime (>= 1.1.0) 70 | activesupport (7.0.4.2) 71 | concurrent-ruby (~> 1.0, >= 1.0.2) 72 | i18n (>= 1.6, < 2) 73 | minitest (>= 5.1) 74 | tzinfo (~> 2.0) 75 | appraisal (2.4.1) 76 | bundler 77 | rake 78 | thor (>= 0.14.0) 79 | ast (2.4.2) 80 | builder (3.2.4) 81 | coderay (1.1.3) 82 | concurrent-ruby (1.2.2) 83 | crass (1.0.6) 84 | date (3.3.3) 85 | erubi (1.12.0) 86 | globalid (1.1.0) 87 | activesupport (>= 5.0) 88 | i18n (1.12.0) 89 | concurrent-ruby (~> 1.0) 90 | jaro_winkler (1.5.4) 91 | loofah (2.19.1) 92 | crass (~> 1.0.2) 93 | nokogiri (>= 1.5.9) 94 | mail (2.8.1) 95 | mini_mime (>= 0.1.1) 96 | net-imap 97 | net-pop 98 | net-smtp 99 | marcel (1.0.2) 100 | method_source (1.0.0) 101 | mini_mime (1.1.2) 102 | minitest (5.17.0) 103 | net-imap (0.3.4) 104 | date 105 | net-protocol 106 | net-pop (0.1.2) 107 | net-protocol 108 | net-protocol (0.2.1) 109 | timeout 110 | net-smtp (0.3.3) 111 | net-protocol 112 | nio4r (2.5.8) 113 | nokogiri (1.14.2-x86_64-darwin) 114 | racc (~> 1.4) 115 | parallel (1.22.1) 116 | parser (3.1.3.0) 117 | ast (~> 2.4.1) 118 | pry (0.14.1) 119 | coderay (~> 1.1) 120 | method_source (~> 1.0) 121 | racc (1.6.2) 122 | rack (2.2.6.3) 123 | rack-test (2.0.2) 124 | rack (>= 1.3) 125 | rails (7.0.4.2) 126 | actioncable (= 7.0.4.2) 127 | actionmailbox (= 7.0.4.2) 128 | actionmailer (= 7.0.4.2) 129 | actionpack (= 7.0.4.2) 130 | actiontext (= 7.0.4.2) 131 | actionview (= 7.0.4.2) 132 | activejob (= 7.0.4.2) 133 | activemodel (= 7.0.4.2) 134 | activerecord (= 7.0.4.2) 135 | activestorage (= 7.0.4.2) 136 | activesupport (= 7.0.4.2) 137 | bundler (>= 1.15.0) 138 | railties (= 7.0.4.2) 139 | rails-dom-testing (2.0.3) 140 | activesupport (>= 4.2.0) 141 | nokogiri (>= 1.6) 142 | rails-html-sanitizer (1.5.0) 143 | loofah (~> 2.19, >= 2.19.1) 144 | railties (7.0.4.2) 145 | actionpack (= 7.0.4.2) 146 | activesupport (= 7.0.4.2) 147 | method_source 148 | rake (>= 12.2) 149 | thor (~> 1.0) 150 | zeitwerk (~> 2.5) 151 | rainbow (3.1.1) 152 | rake (13.0.6) 153 | rubocop (0.71.0) 154 | jaro_winkler (~> 1.5.1) 155 | parallel (~> 1.10) 156 | parser (>= 2.6) 157 | rainbow (>= 2.2.2, < 4.0) 158 | ruby-progressbar (~> 1.7) 159 | unicode-display_width (>= 1.4.0, < 1.7) 160 | ruby-progressbar (1.11.0) 161 | sqlite3 (1.5.4-x86_64-darwin) 162 | thor (1.2.1) 163 | timeout (0.3.2) 164 | tzinfo (2.0.6) 165 | concurrent-ruby (~> 1.0) 166 | unicode-display_width (1.6.1) 167 | websocket-driver (0.7.5) 168 | websocket-extensions (>= 0.1.0) 169 | websocket-extensions (0.1.5) 170 | zeitwerk (2.6.7) 171 | 172 | PLATFORMS 173 | x86_64-darwin-21 174 | 175 | DEPENDENCIES 176 | appraisal 177 | procore-sift! 178 | pry 179 | rails (~> 7.0.0) 180 | rake 181 | rubocop (= 0.71.0) 182 | sqlite3 183 | 184 | BUNDLED WITH 185 | 2.3.26 186 | -------------------------------------------------------------------------------- /lib/procore-sift.rb: -------------------------------------------------------------------------------- 1 | require "sift/filter" 2 | require "sift/filter_validator" 3 | require "sift/filtrator" 4 | require "sift/sort" 5 | require "sift/subset_comparator" 6 | require "sift/type_validator" 7 | require "sift/parameter" 8 | require "sift/value_parser" 9 | require "sift/scope_handler" 10 | require "sift/where_handler" 11 | require "sift/validators/valid_int_validator" 12 | require "sift/validators/valid_date_range_validator" 13 | require "sift/validators/valid_json_validator" 14 | 15 | module Sift 16 | extend ActiveSupport::Concern 17 | 18 | def filtrate(collection) 19 | Filtrator.filter(collection, params, filters) 20 | end 21 | 22 | def filter_params 23 | params.fetch(:filters, {}) 24 | end 25 | 26 | def sort_params 27 | params.fetch(:sort, "").split(",") if filters.any? { |filter| filter.is_a?(Sort) } 28 | end 29 | 30 | def filters_valid? 31 | filter_validator.valid? 32 | end 33 | 34 | def filter_errors 35 | filter_validator.errors.messages 36 | end 37 | 38 | private 39 | 40 | def filter_validator 41 | @_filter_validator ||= FilterValidator.build( 42 | filters: filters, 43 | sort_fields: sort_fields, 44 | filter_params: filter_params, 45 | sort_params: sort_params, 46 | ) 47 | end 48 | 49 | def filters 50 | self.class.ancestors 51 | .take_while { |klass| klass.name != "Sift" } 52 | .flat_map { |klass| klass.try(:filters) } 53 | .compact 54 | .uniq { |f| [f.param, f.class] } 55 | end 56 | 57 | def sorts_exist? 58 | filters.any? { |filter| filter.is_a?(Sort) } 59 | end 60 | 61 | def sort_fields 62 | self.class.ancestors 63 | .take_while { |klass| klass.name != "Sift" } 64 | .flat_map { |klass| klass.try(:sort_fields) } 65 | .compact 66 | end 67 | 68 | class_methods do 69 | def filter_on(parameter, type:, internal_name: parameter, default: nil, validate: nil, scope_params: [], tap: nil) 70 | filters << Filter.new(parameter, type, internal_name, default, validate, scope_params, tap) 71 | end 72 | 73 | def filters 74 | @_filters ||= [] 75 | end 76 | 77 | # TODO: this is only used in tests, can I kill it? 78 | def reset_filters 79 | @_filters = [] 80 | end 81 | 82 | def sort_fields 83 | @_sort_fields ||= [] 84 | end 85 | 86 | def sort_on(parameter, type:, internal_name: parameter, scope_params: []) 87 | filters << Sort.new(parameter, type, internal_name, scope_params) 88 | sort_fields << parameter.to_s 89 | sort_fields << "-#{parameter}" 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/sift/filter.rb: -------------------------------------------------------------------------------- 1 | module Sift 2 | # Filter describes the way a parameter maps to a database column 3 | # and the type information helpful for validating input. 4 | class Filter 5 | attr_reader :parameter, :default, :custom_validate, :scope_params 6 | 7 | def initialize(param, type, internal_name, default, custom_validate = nil, scope_params = [], tap = ->(value, _params) { value }) 8 | @parameter = Parameter.new(param, type, internal_name) 9 | @default = default 10 | @custom_validate = custom_validate 11 | @scope_params = scope_params 12 | @tap = tap 13 | raise ArgumentError, "scope_params must be an array of symbols" unless valid_scope_params?(scope_params) 14 | raise "unknown filter type: #{type}" unless type_validator.valid_type? 15 | end 16 | 17 | def validation(_sort) 18 | type_validator.validate 19 | end 20 | 21 | # rubocop:disable Lint/UnusedMethodArgument 22 | def apply!(collection, value:, active_sorts_hash:, params: {}) 23 | if not_processable?(value) 24 | collection 25 | elsif should_apply_default?(value) 26 | default.call(collection) 27 | else 28 | parameterized_values = parameterize(value) 29 | processed_values = @tap.present? ? @tap.call(parameterized_values, params) : parameterized_values 30 | handler.call(collection, processed_values, params, scope_params) 31 | end 32 | end 33 | # rubocop:enable Lint/UnusedMethodArgument 34 | 35 | def always_active? 36 | false 37 | end 38 | 39 | def validation_field 40 | parameter.param 41 | end 42 | 43 | def type_validator 44 | @type_validator ||= Sift::TypeValidator.new(param, type) 45 | end 46 | 47 | def type 48 | parameter.type 49 | end 50 | 51 | def param 52 | parameter.param 53 | end 54 | 55 | private 56 | 57 | def parameterize(value) 58 | ValueParser.new(value: value, type: parameter.type, options: parameter.parse_options).parse 59 | end 60 | 61 | def not_processable?(value) 62 | value.nil? && default.nil? 63 | end 64 | 65 | def should_apply_default?(value) 66 | value.nil? && !default.nil? 67 | end 68 | 69 | def mapped_scope_params(params) 70 | scope_params.each_with_object({}) do |scope_param, hash| 71 | hash[scope_param] = params.fetch(scope_param) 72 | end 73 | end 74 | 75 | def valid_scope_params?(scope_params) 76 | scope_params.is_a?(Array) && scope_params.all? { |symbol| symbol.is_a?(Symbol) } 77 | end 78 | 79 | def handler 80 | parameter.handler 81 | end 82 | 83 | def supports_ranges? 84 | parameter.supports_ranges? 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/sift/filter_validator.rb: -------------------------------------------------------------------------------- 1 | # Here be dragons: 2 | # there are two forms of metaprogramming in this file 3 | # instance variables are being dynamically set based on the param name 4 | # and we are class evaling `validates` to create dynamic validations 5 | # based on the filters being validated. 6 | module Sift 7 | class FilterValidator 8 | include ActiveModel::Validations 9 | 10 | def self.build(filters:, sort_fields:, filter_params:, sort_params:) 11 | unique_validations_filters = filters.uniq(&:validation_field) 12 | 13 | klass = Class.new(self) do 14 | def self.model_name 15 | ActiveModel::Name.new(self, nil, "temp") 16 | end 17 | 18 | attr_accessor(*unique_validations_filters.map(&:validation_field)) 19 | 20 | unique_validations_filters.each do |filter| 21 | if has_custom_validation?(filter, filter_params) 22 | validate filter.custom_validate 23 | elsif has_validation?(filter, filter_params, sort_fields) 24 | validates filter.validation_field.to_sym, filter.validation(sort_fields) 25 | end 26 | end 27 | end 28 | 29 | klass.new(filters, filter_params: filter_params, sort_params: sort_params) 30 | end 31 | 32 | def self.has_custom_validation?(filter, filter_params) 33 | filter_params[filter.validation_field] && filter.custom_validate 34 | end 35 | 36 | def self.has_validation?(filter, filter_params, sort_fields) 37 | (filter_params[filter.validation_field] && filter.validation(sort_fields)) || filter.validation_field == :sort 38 | end 39 | 40 | def initialize(filters, filter_params:, sort_params:) 41 | @filter_params = filter_params 42 | @sort_params = sort_params 43 | 44 | filters.each do |filter| 45 | instance_variable_set("@#{filter.validation_field}", to_type(filter)) 46 | end 47 | end 48 | 49 | private 50 | 51 | attr_reader(:filter_params, :sort_params) 52 | 53 | def to_type(filter) 54 | if filter.type == :boolean 55 | ActiveRecord::Type::Boolean.new.cast(filter_params[filter.param]) 56 | elsif filter.validation_field == :sort 57 | sort_params 58 | else 59 | filter_params[filter.param] 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/sift/filtrator.rb: -------------------------------------------------------------------------------- 1 | module Sift 2 | # Filtrator takes a collection, params and a set of filters 3 | # and applies them to create a new active record collection 4 | # with those filters applied. 5 | class Filtrator 6 | attr_reader :collection, :params, :filters, :sort 7 | 8 | def self.filter(collection, params, filters, sort = []) 9 | new(collection, params, sort, filters).filter 10 | end 11 | 12 | def initialize(collection, params, _sort, filters = []) 13 | @collection = collection 14 | @params = params 15 | @filters = filters 16 | @sort = params.fetch(:sort, "").split(",") if filters.any? { |filter| filter.is_a?(Sort) } 17 | end 18 | 19 | def filter 20 | active_filters.reduce(collection) do |col, filter| 21 | apply(col, filter) 22 | end 23 | end 24 | 25 | private 26 | 27 | def apply(collection, filter) 28 | filter.apply!(collection, value: filter_params[filter.param], active_sorts_hash: active_sorts_hash, params: params) 29 | end 30 | 31 | def filter_params 32 | params.fetch(:filters, {}) 33 | end 34 | 35 | def active_sorts_hash 36 | active_sorts_hash = {} 37 | Array(sort).each do |s| 38 | if s.starts_with?("-") 39 | active_sorts_hash[s[1..-1].to_sym] = :desc 40 | else 41 | active_sorts_hash[s.to_sym] = :asc 42 | end 43 | end 44 | active_sorts_hash 45 | end 46 | 47 | def active_filters 48 | filters.select do |filter| 49 | filter_params[filter.param].present? || filter.default || filter.always_active? 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/sift/parameter.rb: -------------------------------------------------------------------------------- 1 | module Sift 2 | # Value Object that wraps some handling of filter params 3 | class Parameter 4 | attr_reader :param, :type, :internal_name 5 | 6 | def initialize(param, type, internal_name = param) 7 | @param = param 8 | @type = type 9 | @internal_name = internal_name 10 | end 11 | 12 | def parse_options 13 | { 14 | supports_boolean: supports_boolean?, 15 | supports_ranges: supports_ranges?, 16 | supports_json: supports_json?, 17 | supports_json_object: supports_json_object? 18 | } 19 | end 20 | 21 | def handler 22 | if type == :scope 23 | ScopeHandler.new(self) 24 | else 25 | WhereHandler.new(self) 26 | end 27 | end 28 | 29 | private 30 | 31 | def supports_ranges? 32 | ![:string, :text, :scope].include?(type) 33 | end 34 | 35 | def supports_json? 36 | [:int, :jsonb].include?(type) 37 | end 38 | 39 | def supports_json_object? 40 | type == :jsonb 41 | end 42 | 43 | def supports_boolean? 44 | type == :boolean 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/sift/scope_handler.rb: -------------------------------------------------------------------------------- 1 | module Sift 2 | class ScopeHandler 3 | def initialize(param) 4 | @param = param 5 | end 6 | 7 | def call(collection, value, params, scope_params) 8 | collection.public_send(@param.internal_name, *scope_parameters(value, params, scope_params)) 9 | end 10 | 11 | def scope_parameters(value, params, scope_params) 12 | if scope_params.empty? 13 | [value] 14 | else 15 | [value, mapped_scope_params(params, scope_params)] 16 | end 17 | end 18 | 19 | def mapped_scope_params(params, scope_params) 20 | scope_params.each_with_object({}) do |scope_param, hash| 21 | hash[scope_param] = params.fetch(scope_param) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/sift/sort.rb: -------------------------------------------------------------------------------- 1 | module Sift 2 | # Sort provides the same interface as a filter, 3 | # but instead of applying a `where` to the collection 4 | # it applies an `order`. 5 | class Sort 6 | attr_reader :parameter, :scope_params 7 | 8 | WHITELIST_TYPES = [:int, 9 | :decimal, 10 | :string, 11 | :text, 12 | :date, 13 | :time, 14 | :datetime, 15 | :scope].freeze 16 | 17 | def initialize(param, type, internal_name = param, scope_params = []) 18 | raise "unknown filter type: #{type}" unless WHITELIST_TYPES.include?(type) 19 | raise "scope params must be an array" unless scope_params.is_a?(Array) 20 | 21 | @parameter = Parameter.new(param, type, internal_name) 22 | @scope_params = scope_params 23 | end 24 | 25 | def default 26 | # TODO: we can support defaults here later 27 | false 28 | end 29 | 30 | # rubocop:disable Lint/UnusedMethodArgument 31 | # rubocop:disable Metrics/PerceivedComplexity 32 | def apply!(collection, value:, active_sorts_hash:, params: {}) 33 | if type == :scope 34 | if active_sorts_hash.keys.include?(param) 35 | collection.public_send(internal_name, *mapped_scope_params(active_sorts_hash[param], params)) 36 | elsif default.present? 37 | # Stubbed because currently Sift::Sort does not respect default 38 | # default.call(collection) 39 | collection 40 | else 41 | collection 42 | end 43 | elsif type == :string || type == :text 44 | if active_sorts_hash.keys.include?(param) 45 | collection.order("LOWER(#{internal_name}) #{individual_sort_hash(active_sorts_hash)[internal_name]}") 46 | else 47 | collection 48 | end 49 | else 50 | collection.order(individual_sort_hash(active_sorts_hash)) 51 | end 52 | end 53 | # rubocop:enable Metrics/PerceivedComplexity 54 | # rubocop:enable Lint/UnusedMethodArgument 55 | 56 | def always_active? 57 | true 58 | end 59 | 60 | def validation_field 61 | :sort 62 | end 63 | 64 | def validation(sort) 65 | { 66 | inclusion: { in: SubsetComparator.new(sort) }, 67 | allow_nil: true 68 | } 69 | end 70 | 71 | def type 72 | parameter.type 73 | end 74 | 75 | def param 76 | parameter.param 77 | end 78 | 79 | private 80 | 81 | def mapped_scope_params(direction, params) 82 | scope_params.map do |scope_param| 83 | if scope_param == :direction 84 | direction 85 | elsif scope_param.is_a?(Proc) 86 | scope_param.call 87 | elsif params.include?(scope_param) 88 | params[scope_param] 89 | else 90 | scope_param 91 | end 92 | end 93 | end 94 | 95 | def individual_sort_hash(active_sorts_hash) 96 | if active_sorts_hash.include?(param) 97 | { internal_name => active_sorts_hash[param] } 98 | else 99 | {} 100 | end 101 | end 102 | 103 | def internal_name 104 | parameter.internal_name 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/sift/subset_comparator.rb: -------------------------------------------------------------------------------- 1 | module Sift 2 | class SubsetComparator 3 | def initialize(array) 4 | @array = array 5 | end 6 | 7 | def include?(other) 8 | other = [other] unless other.is_a?(Array) 9 | 10 | @array.to_set >= other.to_set 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/sift/type_validator.rb: -------------------------------------------------------------------------------- 1 | module Sift 2 | # TypeValidator validates that the incoming param is of the specified type 3 | class TypeValidator 4 | DATETIME_RANGE_PATTERN = { format: { with: /\A.+(?:[^.]\.\.\.[^.]).+\z/, message: "must be a range" }, valid_date_range: true }.freeze 5 | DECIMAL_PATTERN = { numericality: true, allow_nil: true }.freeze 6 | BOOLEAN_PATTERN = { inclusion: { in: [true, false] }, allow_nil: true }.freeze 7 | JSON_PATTERN = { valid_json: true }.freeze 8 | 9 | WHITELIST_TYPES = [:int, 10 | :decimal, 11 | :boolean, 12 | :string, 13 | :text, 14 | :date, 15 | :time, 16 | :datetime, 17 | :scope, 18 | :jsonb].freeze 19 | 20 | def initialize(param, type) 21 | @param = param 22 | @type = type 23 | end 24 | 25 | attr_reader :param, :type 26 | 27 | def validate 28 | case type 29 | when :datetime, :date, :time 30 | DATETIME_RANGE_PATTERN 31 | when :int 32 | valid_int? 33 | when :decimal 34 | DECIMAL_PATTERN 35 | when :boolean 36 | BOOLEAN_PATTERN 37 | when :jsonb 38 | JSON_PATTERN 39 | end 40 | end 41 | 42 | def valid_type? 43 | WHITELIST_TYPES.include?(type) 44 | end 45 | 46 | private 47 | 48 | def valid_int? 49 | { valid_int: true } 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/sift/validators/valid_date_range_validator.rb: -------------------------------------------------------------------------------- 1 | class ValidDateRangeValidator < ActiveModel::EachValidator 2 | def validate_each(record, attribute, value) 3 | record.errors.add attribute, "is invalid" unless valid_date_range?(value) 4 | end 5 | 6 | private 7 | 8 | def valid_date_range?(date_range) 9 | from_date_string, end_date_string = date_range.to_s.split("...") 10 | return true unless end_date_string # validated by other validator 11 | 12 | [from_date_string, end_date_string].all? { |date| valid_date?(date) } 13 | end 14 | 15 | def valid_date?(date) 16 | !!DateTime.parse(date.to_s) 17 | rescue ArgumentError 18 | false 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/sift/validators/valid_int_validator.rb: -------------------------------------------------------------------------------- 1 | class ValidIntValidator < ActiveModel::EachValidator 2 | def validate_each(record, attribute, value) 3 | record.errors.add attribute, (options[:message] || "must be integer, array of integers, or range") unless 4 | valid_int?(value) 5 | end 6 | 7 | private 8 | 9 | def valid_int?(value) 10 | integer_array?(value) || integer_or_range?(value) 11 | end 12 | 13 | def integer_array?(value) 14 | if value.is_a?(String) 15 | value = Sift::ValueParser.new(value: value).array_from_json 16 | end 17 | 18 | value.is_a?(Array) && value.any? && value.all? { |v| integer_or_range?(v) } 19 | end 20 | 21 | def integer_or_range?(value) 22 | !!(/\A\d+(...\d+)?\z/ =~ value.to_s) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/sift/validators/valid_json_validator.rb: -------------------------------------------------------------------------------- 1 | class ValidJsonValidator < ActiveModel::EachValidator 2 | def validate_each(record, attribute, value) 3 | record.errors.add attribute, "must be a valid JSON" unless valid_json?(value) 4 | end 5 | 6 | private 7 | 8 | def valid_json?(value) 9 | value = value.strip if value.is_a?(String) 10 | JSON.parse(value) 11 | rescue JSON::ParserError 12 | false 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/sift/value_parser.rb: -------------------------------------------------------------------------------- 1 | module Sift 2 | class ValueParser 3 | def initialize(value:, type: nil, options: {}) 4 | @value = value 5 | @supports_boolean = options.fetch(:supports_boolean, false) 6 | @supports_ranges = options.fetch(:supports_ranges, false) 7 | @supports_json = options.fetch(:supports_json, false) 8 | @supports_json_object = options.fetch(:supports_json_object, false) 9 | @value = normalized_value(value, type) 10 | end 11 | 12 | def parse 13 | @_result ||= 14 | if parse_as_range? 15 | range_value 16 | elsif parse_as_boolean? 17 | boolean_value 18 | elsif parse_as_json? 19 | supports_json_object ? parse_json_and_values : array_from_json 20 | else 21 | value 22 | end 23 | end 24 | 25 | def parse_json(string) 26 | JSON.parse(string) 27 | rescue JSON::ParserError 28 | string 29 | end 30 | 31 | def parse_json_and_values 32 | parsed_jsonb = parse_json(value) 33 | return parsed_jsonb if parsed_jsonb.is_a?(Array) || parsed_jsonb.is_a?(String) 34 | 35 | parsed_jsonb.each_with_object({}) do |key_value, hash| 36 | key = key_value.first 37 | value = key_value.last 38 | hash[key] = value.is_a?(String) ? parse_json(value) : value 39 | end 40 | end 41 | 42 | def array_from_json 43 | result = parse_json(value) 44 | if result.is_a?(Array) 45 | result 46 | else 47 | value 48 | end 49 | end 50 | 51 | private 52 | 53 | attr_reader :value, :type, :supports_boolean, :supports_json, :supports_json_object, :supports_ranges 54 | 55 | def parse_as_range?(raw_value=value) 56 | supports_ranges && raw_value.to_s.include?("...") 57 | end 58 | 59 | def range_value 60 | Range.new(*value.split("...")) 61 | end 62 | 63 | def parse_as_json? 64 | supports_json && value.is_a?(String) 65 | end 66 | 67 | def parse_as_boolean? 68 | supports_boolean 69 | end 70 | 71 | def boolean_value 72 | ActiveRecord::Type::Boolean.new.cast(value) 73 | end 74 | 75 | def normalized_value(raw_value, type) 76 | if type == :datetime && parse_as_range?(raw_value) 77 | normalized_date_range(raw_value) 78 | else 79 | raw_value 80 | end 81 | end 82 | 83 | def normalized_date_range(raw_value) 84 | from_date_string, end_date_string = raw_value.split("...") 85 | return unless end_date_string 86 | 87 | parsed_dates = [from_date_string, end_date_string].map do |date_string| 88 | begin 89 | DateTime.parse(date_string.to_s) 90 | rescue StandardError 91 | date_string 92 | end 93 | end 94 | 95 | parsed_dates.join("...") 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/sift/version.rb: -------------------------------------------------------------------------------- 1 | module Sift 2 | VERSION = "1.0.0".freeze 3 | end 4 | -------------------------------------------------------------------------------- /lib/sift/where_handler.rb: -------------------------------------------------------------------------------- 1 | module Sift 2 | class WhereHandler 3 | def initialize(param) 4 | @param = param 5 | end 6 | 7 | def call(collection, value, _params, _scope_params) 8 | if @param.type == :jsonb 9 | apply_jsonb_conditions(collection, value) 10 | else 11 | collection.where(@param.internal_name => value) 12 | end 13 | end 14 | 15 | private 16 | 17 | def apply_jsonb_conditions(collection, value) 18 | return collection.where("#{@param.internal_name} @> ?", val.to_s) if value.is_a?(Array) 19 | 20 | value.each do |key, val| 21 | collection = if val.is_a?(Array) 22 | elements = Hash[val.each_with_index.map { |item, i| ["value_#{i}".to_sym, item.to_s] } ] 23 | elements[:all_values] = val.compact.map(&:to_s) 24 | main_condition = "('{' || TRANSLATE(#{@param.internal_name}->>'#{key}', '[]','') || '}')::text[] && ARRAY[:all_values]" 25 | sub_conditions = val.each_with_index.map do |element, i| 26 | "#{@param.internal_name}->>'#{key}' #{element === nil ? 'IS NULL' : "= :value_#{i}"}" 27 | end.join(' OR ') 28 | collection.where("(#{main_condition}) OR (#{sub_conditions})", elements) 29 | else 30 | collection.where("#{@param.internal_name}->>'#{key}' = ?", val.to_s) 31 | end 32 | end 33 | collection 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/tasks/filterable_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :sift do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /sift.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.push File.expand_path("lib", __dir__) 2 | 3 | # Maintain your gem's version: 4 | require "sift/version" 5 | 6 | # Describe your gem and declare its dependencies: 7 | Gem::Specification.new do |s| 8 | s.name = "procore-sift" 9 | s.version = Sift::VERSION 10 | s.authors = ["Procore Technologies"] 11 | s.email = ["dev@procore.com"] 12 | s.homepage = "https://github.com/procore/sift" 13 | s.summary = "Summary of Sift." 14 | s.description = "Easily write arbitrary filters" 15 | s.license = "MIT" 16 | 17 | s.files = Dir["{app,config,db,lib}/**/*", 18 | "MIT-LICENSE", 19 | "Rakefile", 20 | "README.md"] 21 | 22 | s.required_ruby_version = ">= 2.7.0" 23 | 24 | s.add_dependency "activerecord", ">= 6.1" 25 | 26 | s.add_development_dependency "pry" 27 | s.add_development_dependency "rails", ">= 6.1" 28 | s.add_development_dependency "rake" 29 | s.add_development_dependency "rubocop", "0.71.0" 30 | s.add_development_dependency "sqlite3" 31 | s.add_development_dependency "appraisal" 32 | end 33 | -------------------------------------------------------------------------------- /test/controller_inheritance_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class PostsInheritanceTest < ActionDispatch::IntegrationTest 4 | test "it works" do 5 | post = Post.create! 6 | 7 | get("/posts_alt") 8 | 9 | json = JSON.parse(@response.body) 10 | assert_equal 1, json.size 11 | assert_equal(post.id, json.first["id"]) 12 | end 13 | 14 | test "it inherits filter from parent controller" do 15 | post = Post.create! 16 | Post.create! 17 | 18 | get("/posts_alt", params: { filters: { id: post.id } }) 19 | 20 | json = JSON.parse(@response.body) 21 | assert_equal 1, json.size 22 | assert_equal post.id, json.first["id"] 23 | end 24 | 25 | test "it inherits sort from parent controller" do 26 | Post.create!(title: "z") 27 | Post.create!(title: "a") 28 | 29 | get("/posts_alt", params: { sort: "title" }) 30 | 31 | json = JSON.parse(@response.body, object_class: OpenStruct) 32 | assert_equal ["a", "z"], json.map(&:title) 33 | end 34 | 35 | test "it overrides inherited body filter with priority filter" do 36 | Post.create!(priority: 3) 37 | Post.create!(priority: 1) 38 | Post.create!(priority: 2) 39 | get("/posts_alt", params: { filters: { body: [2, 3] } }) 40 | 41 | json = JSON.parse(@response.body, object_class: OpenStruct) 42 | assert_equal [3, 2], json.map(&:priority) 43 | end 44 | 45 | test "it overrides inherited body sort with priority sort" do 46 | Post.create!(priority: 3) 47 | Post.create!(priority: 1) 48 | Post.create!(priority: 2) 49 | get("/posts_alt", params: { sort: "body" }) 50 | 51 | json = JSON.parse(@response.body, object_class: OpenStruct) 52 | assert_equal [1, 2, 3], json.map(&:priority) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class PostsControllerTest < ActionDispatch::IntegrationTest 4 | test "it works" do 5 | post = Post.create! 6 | 7 | get("/posts") 8 | 9 | json = JSON.parse(@response.body) 10 | assert_equal 1, json.size 11 | assert_equal(post.id, json.first["id"]) 12 | end 13 | 14 | test "it filters on id by value" do 15 | post = Post.create! 16 | Post.create! 17 | 18 | get("/posts", params: { filters: { id: post.id } }) 19 | 20 | json = JSON.parse(@response.body) 21 | assert_equal 1, json.size 22 | assert_equal post.id, json.first["id"] 23 | end 24 | 25 | test "it filters on id by value for a JSON string array" do 26 | post1 = Post.create! 27 | post2 = Post.create! 28 | _post3 = Post.create! 29 | 30 | get("/posts", params: { filters: { id: "[#{post1.id},#{post2.id}]" } }) 31 | 32 | json = JSON.parse(@response.body) 33 | assert_equal 2, json.size 34 | ids_array = json.map { |json_hash| json_hash["id"] } 35 | assert_equal [post1.id, post2.id], ids_array 36 | end 37 | 38 | test "it fails validation for a JSON string array included with other integers" do 39 | post1 = Post.create! 40 | post2 = Post.create! 41 | post3 = Post.create! 42 | 43 | get("/posts", params: { filters: { id: [post3.id, "[#{post1.id},#{post2.id}]"] } }) 44 | 45 | assert_equal "400", @response.code 46 | json = JSON.parse(@response.body) 47 | assert_equal json, "errors" => { "id" => ["must be integer, array of integers, or range"] } 48 | end 49 | 50 | test "it filters on JSON string in combination with other filters to return values that meet all conditions" do 51 | post1 = Post.create!(rating: 1.25) 52 | post2 = Post.create!(rating: 1.75) 53 | 54 | get("/posts", params: { filters: { id: "[#{post1.id},#{post2.id}]", rating: post1.rating } }) 55 | 56 | json = JSON.parse(@response.body) 57 | assert_equal json.map { |post| post["id"] }, [post1.id] 58 | end 59 | 60 | test "it filters on decimals" do 61 | post = Post.create!(rating: 4.75) 62 | Post.create! 63 | 64 | get("/posts", params: { filters: { rating: post.rating } }) 65 | 66 | json = JSON.parse(@response.body) 67 | assert_equal 1, json.size 68 | assert_equal post.id, json.first["id"] 69 | end 70 | 71 | test "it filters on booleans" do 72 | post = Post.create!(visible: true) 73 | Post.create! 74 | 75 | get("/posts", params: { filters: { visible: "1" } }) 76 | 77 | json = JSON.parse(@response.body) 78 | assert_equal 1, json.size 79 | assert_equal post.id, json.first["id"] 80 | end 81 | 82 | test "it filters on booleans false" do 83 | post = Post.create!(visible: false) 84 | Post.create! 85 | 86 | get("/posts", params: { filters: { visible: "0" } }) 87 | 88 | json = JSON.parse(@response.body) 89 | assert_equal 1, json.size 90 | assert_equal post.id, json.first["id"] 91 | end 92 | 93 | test "it invalidates id" do 94 | Post.create!(visible: false) 95 | Post.create! 96 | expected_json = { "errors" => { "id" => ["must be integer, array of integers, or range"] } } 97 | 98 | get("/posts", params: { filters: { id: "poopie" } }) 99 | 100 | json = JSON.parse(@response.body) 101 | 102 | assert_equal expected_json, json 103 | end 104 | 105 | test "it filters on id with a range" do 106 | post1 = Post.create! 107 | post2 = Post.create! 108 | Post.create! 109 | get("/posts", params: { filters: { id: "#{post1.id}...#{post2.id}" } }) 110 | 111 | json = JSON.parse(@response.body) 112 | assert_equal 2, json.size 113 | end 114 | 115 | test "it filters on id with an array" do 116 | post1 = Post.create! 117 | post2 = Post.create! 118 | Post.create! 119 | get("/posts", params: { filters: { id: [post1.id, post2.id] } }) 120 | 121 | json = JSON.parse(@response.body) 122 | assert_equal 2, json.size 123 | end 124 | 125 | test "it filters on named scope" do 126 | Post.create!(expiration: 3.days.ago) 127 | Post.create!(expiration: 1.days.ago) 128 | get("/posts", params: { filters: { expired_before: 2.days.ago } }) 129 | 130 | json = JSON.parse(@response.body) 131 | assert_equal 1, json.size 132 | end 133 | 134 | test "it can filter on a scope with multiple values" do 135 | Post.create!(body: "hi") 136 | Post.create!(body: "hello") 137 | Post.create!(body: "hola") 138 | get("/posts", params: { filters: { body2: ["hi", "hello"] } }) 139 | 140 | json = JSON.parse(@response.body) 141 | assert_equal 2, json.size 142 | end 143 | 144 | test "the param can have a different name from the internal name" do 145 | post = Post.create!(title: "hi") 146 | Post.create!(title: "friend") 147 | get("/posts", params: { filters: { french_bread: post.title } }) 148 | 149 | json = JSON.parse(@response.body) 150 | assert_equal 1, json.size 151 | end 152 | 153 | test "it sorts" do 154 | Post.create!(title: "z") 155 | Post.create!(title: "a") 156 | 157 | get("/posts", params: { sort: "title" }) 158 | 159 | json = JSON.parse(@response.body, object_class: OpenStruct) 160 | assert_equal ["a", "z"], json.map(&:title) 161 | end 162 | 163 | test "it sorts descending" do 164 | Post.create!(title: "z") 165 | Post.create!(title: "a") 166 | 167 | get("/posts", params: { sort: "-title" }) 168 | 169 | json = JSON.parse(@response.body, object_class: OpenStruct) 170 | assert_equal ["z", "a"], json.map(&:title) 171 | end 172 | 173 | test "it can do multiple sorts" do 174 | Post.create!(title: "z") 175 | Post.create!(title: "g", priority: 1) 176 | Post.create!(title: "g", priority: 10) 177 | Post.create!(title: "a") 178 | 179 | get("/posts", params: { sort: "-title,-priority" }) 180 | 181 | json = JSON.parse(@response.body, object_class: OpenStruct) 182 | assert_equal ["z", "g", "g", "a"], json.map(&:title) 183 | assert_equal [nil, 10, 1, nil], json.map(&:priority) 184 | end 185 | 186 | test "it errors on unknown fields" do 187 | expected_json = { "errors" => { "sort" => ["is not included in the list"] } } 188 | 189 | get("/posts", params: { sort: "-not-there" }) 190 | 191 | json = JSON.parse(@response.body) 192 | assert_equal expected_json, json 193 | end 194 | 195 | test "it custom filters" do 196 | post = Post.create! 197 | Post.create! 198 | 199 | get("/posts", params: { filters: { id_array: [post.id] } }) 200 | json = JSON.parse(@response.body) 201 | assert_equal 1, json.size 202 | assert_equal post.id, json.first["id"] 203 | end 204 | 205 | test "it respects custom validation logic" do 206 | expected_json = { "errors" => { "id_array" => ["Not all values were valid integers"] } } 207 | post = Post.create! 208 | Post.create! 209 | 210 | get("/posts", params: { filters: { id_array: [post.id, "zebra"] } }) 211 | json = JSON.parse(@response.body) 212 | assert_equal json, expected_json 213 | end 214 | 215 | test "it sorts on string keys" do 216 | Post.create!(title: "a") 217 | Post.create!(title: "b") 218 | Post.create!(title: "z") 219 | get("/posts", params: { "sort" => "-title" }) 220 | json = JSON.parse(@response.body, object_class: OpenStruct) 221 | assert_equal ["z", "b", "a"], json.map(&:title) 222 | end 223 | 224 | test "it sorts on symbol keys" do 225 | Post.create!(title: "a") 226 | Post.create!(title: "b") 227 | Post.create!(title: "z") 228 | get("/posts", params: { sort: "-title" }) 229 | json = JSON.parse(@response.body, object_class: OpenStruct) 230 | assert_equal ["z", "b", "a"], json.map(&:title) 231 | end 232 | 233 | test "it sorts case-insensitively on text/string types" do 234 | Post.create(title: "b") 235 | Post.create(title: "A") 236 | Post.create(title: "C") 237 | Post.create(title: "d") 238 | get("/posts", params: { sort: "title" }) 239 | json = JSON.parse(@response.body, object_class: OpenStruct) 240 | assert_equal ["A", "b", "C", "d"], json.map(&:title) 241 | end 242 | 243 | test "it respects internal name for non-scope sorts" do 244 | Post.create(title: "b") 245 | Post.create(title: "A") 246 | Post.create(title: "C") 247 | Post.create(title: "d") 248 | get("/posts", params: { sort: "foobar" }) 249 | json = JSON.parse(@response.body, object_class: OpenStruct) 250 | assert_equal ["A", "b", "C", "d"], json.map(&:title) 251 | end 252 | 253 | test "it sorts with dependent params" do 254 | Post.create!(body: "b", expiration: "2017-11-11") 255 | Post.create!(body: "A", expiration: "2017-10-10") 256 | Post.create!(body: "C", expiration: "2090-08-08") 257 | 258 | get("/posts", params: { sort: "dynamic_sort", date: "2017-12-12" }) 259 | json = JSON.parse(@response.body) 260 | 261 | assert_equal ["A", "b"], (json.map { |post| post.fetch("body") }) 262 | end 263 | 264 | test "it filters with dependent params" do 265 | Post.create!(priority: 7, expiration: "2017-11-11") 266 | Post.create!(priority: 5, expiration: "2017-10-10") 267 | 268 | get("/posts", params: { filters: { expired_before_and_priority: "2017-12-12" }, priority: 5 }) 269 | json = JSON.parse(@response.body) 270 | 271 | assert_equal [5], (json.map { |post| post.fetch("priority") }) 272 | end 273 | 274 | test "it sorts by datetime range" do 275 | base_date = Date.new(2018, 1, 1) 276 | post1 = Post.create(published_at: base_date) 277 | Post.create(published_at: (base_date + 3.days)) 278 | date_time_range_string = "2017-12-31T00:00:00+00:00...2018-01-02T00:00:00+00:00" 279 | 280 | get("/posts", params: { filters: { published_at: date_time_range_string } }) 281 | 282 | json = JSON.parse(@response.body) 283 | assert_equal [post1.id], (json.map { |post| post.fetch("id") }) 284 | end 285 | 286 | test "it filters on metadata by jsonb key-value" do 287 | post = Post.create!(metadata: { 'a' => 4 }.to_json) 288 | Post.create!(metadata: { 'b' => 5 }.to_json) 289 | 290 | # Stubs are needed because the dummy app DB is not PostgreSQL 291 | instance_mock = Minitest::Mock.new 292 | instance_mock.expect :call, Post.where(id: post.id), [Post.all, Hash, ActionController::Parameters, Array] 293 | 294 | class_mock = Minitest::Mock.new 295 | class_mock.expect :call, instance_mock, [Sift::Parameter] 296 | 297 | Sift::WhereHandler.stub :new, class_mock, [Sift::Parameter] do 298 | get("/posts", params: { filters: { metadata: { 'a' => 4 }.to_json } }) 299 | 300 | json = JSON.parse(@response.body) 301 | assert_equal 1, json.size 302 | assert_equal post.id, json.first["id"] 303 | end 304 | 305 | assert_mock class_mock 306 | assert_mock instance_mock 307 | end 308 | 309 | test "it filters on metadata by jsonb array" do 310 | post = Post.create!(metadata: [1,2,3].to_json) 311 | Post.create!(metadata: [4,5,6].to_json) 312 | 313 | # Stubs are needed because the dummy app DB is not PostgreSQL 314 | instance_mock = Minitest::Mock.new 315 | instance_mock.expect :call, Post.where(id: post.id), [Post.all, Array, ActionController::Parameters, Array] 316 | 317 | class_mock = Minitest::Mock.new 318 | class_mock.expect :call, instance_mock, [Sift::Parameter] 319 | 320 | Sift::WhereHandler.stub :new, class_mock, [Sift::Parameter] do 321 | get("/posts", params: { filters: { metadata: [1,2].to_json } }) 322 | 323 | json = JSON.parse(@response.body) 324 | assert_equal 1, json.size 325 | assert_equal post.id, json.first["id"] 326 | end 327 | 328 | assert_mock class_mock 329 | assert_mock instance_mock 330 | end 331 | end 332 | -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /test/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | 2 | //= link_tree ../images 3 | //= link_directory ../javascripts .js 4 | //= link_directory ../stylesheets .css 5 | -------------------------------------------------------------------------------- /test/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/procore/sift/8962e91f58b2ee4e602c42a87832e4fc3a8b0608/test/dummy/app/assets/images/.keep -------------------------------------------------------------------------------- /test/dummy/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require_tree . 14 | -------------------------------------------------------------------------------- /test/dummy/app/assets/javascripts/cable.js: -------------------------------------------------------------------------------- 1 | // Action Cable provides the framework to deal with WebSockets in Rails. 2 | // You can generate new channels where WebSocket features live using the rails generate channel command. 3 | // 4 | //= require action_cable 5 | //= require_self 6 | //= require_tree ./channels 7 | 8 | (function() { 9 | this.App || (this.App = {}); 10 | 11 | App.cable = ActionCable.createConsumer(); 12 | 13 | }).call(this); 14 | -------------------------------------------------------------------------------- /test/dummy/app/assets/javascripts/channels/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/procore/sift/8962e91f58b2ee4e602c42a87832e4fc3a8b0608/test/dummy/app/assets/javascripts/channels/.keep -------------------------------------------------------------------------------- /test/dummy/app/assets/javascripts/posts.js: -------------------------------------------------------------------------------- 1 | // Place all the behaviors and hooks related to the matching controller here. 2 | // All this logic will automatically be available in application.js. 3 | -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/posts.css: -------------------------------------------------------------------------------- 1 | /* 2 | Place all the styles related to the matching controller here. 3 | They will automatically be included in application.css. 4 | */ 5 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | protect_from_forgery with: :exception 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/procore/sift/8962e91f58b2ee4e602c42a87832e4fc3a8b0608/test/dummy/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/controllers/posts_alt_controller.rb: -------------------------------------------------------------------------------- 1 | class PostsAltController < PostsController 2 | filter_on :body, type: :int, internal_name: :priority 3 | sort_on :body, type: :int, internal_name: :priority 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/posts_controller.rb: -------------------------------------------------------------------------------- 1 | class PostsController < ApplicationController 2 | include Sift 3 | 4 | LOCAL_TIME_ZONE = "America/New_York" 5 | 6 | filter_on :id, type: :int 7 | filter_on :priority, type: :int 8 | filter_on :rating, type: :decimal 9 | filter_on :visible, type: :boolean 10 | filter_on :title, type: :string 11 | filter_on :body, type: :text 12 | filter_on :expiration, type: :date 13 | filter_on :hidden_after, type: :time 14 | filter_on :published_at, type: :datetime 15 | filter_on :expired_before, type: :scope 16 | filter_on :expired_before_and_priority, type: :scope, scope_params: [:priority] 17 | filter_on :metadata, type: :jsonb 18 | 19 | filter_on :french_bread, type: :string, internal_name: :title 20 | filter_on :body2, type: :scope, internal_name: :body2, default: ->(c) { c.order(:body) } 21 | 22 | filter_on :expiration, type: :datetime, tap: ->(value, params) { 23 | value.split("..."). 24 | map do |str| 25 | str.to_date.in_time_zone(LOCAL_TIME_ZONE) 26 | end. 27 | join("...") 28 | } 29 | 30 | # rubocop:disable Style/RescueModifier 31 | filter_on :id_array, type: :int, internal_name: :id, validate: ->(validator) { 32 | value = validator.instance_variable_get("@id_array") 33 | if value.is_a?(Array) 34 | # Verify all variables in the array are integers 35 | unless value.all? { |v| (Integer(v) rescue false) } 36 | validator.errors.add(:id_array, "Not all values were valid integers") 37 | end 38 | elsif !(Integer(value) rescue false) 39 | validator.errors.add(:id_array, "It not an integer") 40 | end 41 | } 42 | # rubocop:enable Style/RescueModifier 43 | 44 | before_action :render_filter_errors, unless: :filters_valid? 45 | 46 | sort_on :title, type: :string 47 | sort_on :priority, type: :string 48 | sort_on :foobar, type: :string, internal_name: :title 49 | sort_on :dynamic_sort, type: :scope, internal_name: :expired_before_ordered_by_body, scope_params: [:date, :direction] 50 | 51 | def index 52 | render json: filtrate(Post.all) 53 | end 54 | 55 | private 56 | 57 | def render_filter_errors 58 | render json: { errors: filter_errors }, status: :bad_request and return 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/posts_helper.rb: -------------------------------------------------------------------------------- 1 | module PostsHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/procore/sift/8962e91f58b2ee4e602c42a87832e4fc3a8b0608/test/dummy/app/models/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/models/post.rb: -------------------------------------------------------------------------------- 1 | class Post < ApplicationRecord 2 | scope :expired_before, ->(date) { 3 | where("expiration < ?", date) 4 | } 5 | 6 | scope :body2, ->(text) { 7 | where(body: text) 8 | } 9 | 10 | scope :body_and_priority, ->(text, priority) { 11 | where(body: text, priority: priority) 12 | } 13 | 14 | scope :order_on_body_no_params, -> { 15 | order(body: :desc) 16 | } 17 | 18 | scope :order_on_body_one_param, ->(direction) { 19 | order(body: direction) 20 | } 21 | 22 | scope :order_on_body_multi_param, ->(body, direction) { 23 | where(body: body).order(id: direction) 24 | } 25 | 26 | scope :expired_before_ordered_by_body, ->(date, direction) { 27 | expired_before(date).order_on_body_one_param(direction) 28 | } 29 | 30 | scope :expired_before_and_priority, ->(date, options) { 31 | expired_before(date).where(priority: options[:priority]) 32 | } 33 | 34 | scope :ordered_expired_before_and_priority, ->(direction, options) { 35 | expired_before_and_priority(options[:date], options).order(id: direction) 36 | } 37 | end 38 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | <%= csrf_meta_tags %> 6 | 7 | <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> 8 | <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> 9 | 10 | 11 | 12 | <%= yield %> 13 | 14 | 15 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /test/dummy/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", __FILE__) 3 | load Gem.bin_path("bundler", "bundle") 4 | -------------------------------------------------------------------------------- /test/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /test/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /test/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "pathname" 3 | require "fileutils" 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path("../../", __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a starting point to setup your application. 15 | # Add necessary setup steps to this file. 16 | 17 | puts "== Installing dependencies ==" 18 | system! "gem install bundler --conservative" 19 | system("bundle check") || system!("bundle install") 20 | 21 | # Install JavaScript dependencies if using Yarn 22 | # system('bin/yarn') 23 | 24 | # puts "\n== Copying sample files ==" 25 | # unless File.exist?('config/database.yml') 26 | # cp 'config/database.yml.sample', 'config/database.yml' 27 | # end 28 | 29 | puts "\n== Preparing database ==" 30 | system! "bin/rails db:setup" 31 | 32 | puts "\n== Removing old logs and tempfiles ==" 33 | system! "bin/rails log:clear tmp:clear" 34 | 35 | puts "\n== Restarting application server ==" 36 | system! "bin/rails restart" 37 | end 38 | -------------------------------------------------------------------------------- /test/dummy/bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "pathname" 3 | require "fileutils" 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path("../../", __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a way to update your development environment automatically. 15 | # Add necessary update steps to this file. 16 | 17 | puts "== Installing dependencies ==" 18 | system! "gem install bundler --conservative" 19 | system("bundle check") || system!("bundle install") 20 | 21 | puts "\n== Updating database ==" 22 | system! "bin/rails db:migrate" 23 | 24 | puts "\n== Removing old logs and tempfiles ==" 25 | system! "bin/rails log:clear tmp:clear" 26 | 27 | puts "\n== Restarting application server ==" 28 | system! "bin/rails restart" 29 | end 30 | -------------------------------------------------------------------------------- /test/dummy/bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | VENDOR_PATH = File.expand_path("..", __dir__) 3 | Dir.chdir(VENDOR_PATH) do 4 | begin 5 | exec "yarnpkg #{ARGV.join(' ')}" 6 | rescue Errno::ENOENT 7 | warn "Yarn executable was not detected in the system." 8 | warn "Download Yarn at https://yarnpkg.com/en/docs/install" 9 | exit 1 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module Dummy 10 | class Application < Rails::Application 11 | # Initialize configuration defaults for originally generated Rails version. 12 | config.load_defaults 6.1 13 | 14 | # Settings in config/environments/* take precedence over those specified here. 15 | # Application configuration should go into files in config/initializers 16 | # -- all .rb files in that directory are automatically loaded. 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /test/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: async 6 | 7 | production: 8 | adapter: redis 9 | url: redis://localhost:6379/1 10 | channel_prefix: dummy_production 11 | -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: 5 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable/disable caching. By default caching is disabled. 16 | if Rails.root.join("tmp/caching-dev.txt").exist? 17 | config.action_controller.perform_caching = true 18 | 19 | config.cache_store = :memory_store 20 | config.public_file_server.headers = { 21 | "Cache-Control" => "public, max-age=#{2.days.seconds.to_i}" 22 | } 23 | else 24 | config.action_controller.perform_caching = false 25 | 26 | config.cache_store = :null_store 27 | end 28 | 29 | # Don't care if the mailer can't send. 30 | config.action_mailer.raise_delivery_errors = false 31 | 32 | config.action_mailer.perform_caching = false 33 | 34 | # Print deprecation notices to the Rails logger. 35 | config.active_support.deprecation = :log 36 | 37 | # Raise an error on page load if there are pending migrations. 38 | config.active_record.migration_error = :page_load 39 | 40 | # Debug mode disables concatenation and preprocessing of assets. 41 | # This option may cause significant delays in view rendering with a large 42 | # number of complex assets. 43 | config.assets.debug = true 44 | 45 | # Suppress logger output for asset requests. 46 | config.assets.quiet = true 47 | 48 | # Raises error for missing translations 49 | # config.action_view.raise_on_missing_translations = true 50 | 51 | # Use an evented file watcher to asynchronously detect changes in source code, 52 | # routes, locales, etc. This feature depends on the listen gem. 53 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 54 | end 55 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Attempt to read encrypted secrets from `config/secrets.yml.enc`. 18 | # Requires an encryption key in `ENV["RAILS_MASTER_KEY"]` or 19 | # `config/secrets.yml.key`. 20 | config.read_encrypted_secrets = true 21 | 22 | # Disable serving static files from the `/public` folder by default since 23 | # Apache or NGINX already handles this. 24 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? 25 | 26 | # Compress JavaScripts and CSS. 27 | config.assets.js_compressor = :uglifier 28 | # config.assets.css_compressor = :sass 29 | 30 | # Do not fallback to assets pipeline if a precompiled asset is missed. 31 | config.assets.compile = false 32 | 33 | # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb 34 | 35 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 36 | # config.action_controller.asset_host = 'http://assets.example.com' 37 | 38 | # Specifies the header that your server uses for sending files. 39 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 40 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 41 | 42 | # Mount Action Cable outside main process or domain 43 | # config.action_cable.mount_path = nil 44 | # config.action_cable.url = 'wss://example.com/cable' 45 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 46 | 47 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 48 | # config.force_ssl = true 49 | 50 | # Use the lowest log level to ensure availability of diagnostic information 51 | # when problems arise. 52 | config.log_level = :debug 53 | 54 | # Prepend all log lines with the following tags. 55 | config.log_tags = [:request_id] 56 | 57 | # Use a different cache store in production. 58 | # config.cache_store = :mem_cache_store 59 | 60 | # Use a real queuing backend for Active Job (and separate queues per environment) 61 | # config.active_job.queue_adapter = :resque 62 | # config.active_job.queue_name_prefix = "dummy_#{Rails.env}" 63 | config.action_mailer.perform_caching = false 64 | 65 | # Ignore bad email addresses and do not raise email delivery errors. 66 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 67 | # config.action_mailer.raise_delivery_errors = false 68 | 69 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 70 | # the I18n.default_locale when a translation cannot be found). 71 | config.i18n.fallbacks = true 72 | 73 | # Send deprecation notices to registered listeners. 74 | config.active_support.deprecation = :notify 75 | 76 | # Use default logging formatter so that PID and timestamp are not suppressed. 77 | config.log_formatter = ::Logger::Formatter.new 78 | 79 | # Use a different logger for distributed setups. 80 | # require 'syslog/logger' 81 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 82 | 83 | if ENV["RAILS_LOG_TO_STDOUT"].present? 84 | logger = ActiveSupport::Logger.new(STDOUT) 85 | logger.formatter = config.log_formatter 86 | config.logger = ActiveSupport::TaggedLogging.new(logger) 87 | end 88 | 89 | # Do not dump schema after migrations. 90 | config.active_record.dump_schema_after_migration = false 91 | end 92 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure public file server for tests with Cache-Control for performance. 16 | config.public_file_server.enabled = true 17 | config.public_file_server.headers = { 18 | "Cache-Control" => "public, max-age=#{1.hour.seconds.to_i}" 19 | } 20 | 21 | # Show full error reports and disable caching. 22 | config.consider_all_requests_local = true 23 | config.action_controller.perform_caching = false 24 | 25 | # Raise exceptions instead of rendering exception templates. 26 | config.action_dispatch.show_exceptions = false 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | config.action_mailer.perform_caching = false 31 | 32 | # Tell Action Mailer not to deliver emails to the real world. 33 | # The :test delivery method accumulates sent emails in the 34 | # ActionMailer::Base.deliveries array. 35 | config.action_mailer.delivery_method = :test 36 | 37 | # Print deprecation notices to the stderr. 38 | config.active_support.deprecation = :stderr 39 | 40 | # Raises error for missing translations 41 | # config.action_view.raise_on_missing_translations = true 42 | end 43 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: "_dummy_session" 4 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /test/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at http://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /test/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | threads threads_count, threads_count 9 | 10 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 11 | # 12 | port ENV.fetch("PORT") { 3000 } 13 | 14 | # Specifies the `environment` that Puma will run in. 15 | # 16 | environment ENV.fetch("RAILS_ENV") { "development" } 17 | 18 | # Specifies the number of `workers` to boot in clustered mode. 19 | # Workers are forked webserver processes. If using threads and workers together 20 | # the concurrency of the application would be max `threads` * `workers`. 21 | # Workers do not work on JRuby or Windows (both of which do not support 22 | # processes). 23 | # 24 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 25 | 26 | # Use the `preload_app!` method when specifying a `workers` number. 27 | # This directive tells Puma to first boot the application and load code 28 | # before forking the application. This takes advantage of Copy On Write 29 | # process behavior so workers use less memory. If you use this option 30 | # you need to make sure to reconnect any threads in the `on_worker_boot` 31 | # block. 32 | # 33 | # preload_app! 34 | 35 | # If you are preloading your application and using Active Record, it's 36 | # recommended that you close any connections to the database before workers 37 | # are forked to prevent connection leakage. 38 | # 39 | # before_fork do 40 | # ActiveRecord::Base.connection_pool.disconnect! if defined?(ActiveRecord) 41 | # end 42 | 43 | # The code in the `on_worker_boot` will be called if you are using 44 | # clustered mode by specifying a number of `workers`. After each worker 45 | # process is booted, this block will be run. If you are using the `preload_app!` 46 | # option, you will want to use this block to reconnect to any threads 47 | # or connections that may have been created at application boot, as Ruby 48 | # cannot share connections between processes. 49 | # 50 | # on_worker_boot do 51 | # ActiveRecord::Base.establish_connection if defined?(ActiveRecord) 52 | # end 53 | # 54 | 55 | # Allow puma to be restarted by `rails restart` command. 56 | plugin :tmp_restart 57 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html 3 | resources :posts 4 | resources :posts_alt 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rails secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | # Shared secrets are available across all environments. 14 | 15 | # shared: 16 | # api_key: a1B2c3D4e5F6 17 | 18 | # Environmental secrets are only available for that specific environment. 19 | 20 | development: 21 | secret_key_base: cd5853a26e0e64d675dfaea7bc1f550601f510336ca5223347508318c89d5d312a6f53ab8ad2997f399cad0dc8c7465915050eec823ad3818247478f5c69739a 22 | 23 | test: 24 | secret_key_base: ccbc410a516baac1f82eea9e488ee84d3b82e474ef974a50e28920fb7c3eec70322216d48a241df1cc15ce5a87a875f9e3123267737d04fe2f323f6aff57513c 25 | 26 | # Do not keep production secrets in the unencrypted secrets file. 27 | # Instead, either read values from the environment. 28 | # Or, use `bin/rails secrets:setup` to configure encrypted secrets 29 | # and move the `production:` environment over there. 30 | 31 | production: 32 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 33 | -------------------------------------------------------------------------------- /test/dummy/config/spring.rb: -------------------------------------------------------------------------------- 1 | [".ruby-version", ".rbenv-vars", "tmp/restart.txt", "tmp/caching-dev.txt"].each { |path| Spring.watch(path) } 2 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20160909014304_create_posts.rb: -------------------------------------------------------------------------------- 1 | class CreatePosts < ActiveRecord::Migration[5.0] 2 | def change 3 | create_table :posts do |t| 4 | t.integer :priority 5 | t.decimal :rating 6 | t.boolean :visible 7 | t.string :title 8 | t.text :body 9 | t.date :expiration 10 | t.time :hidden_after 11 | t.datetime :published_at 12 | 13 | t.timestamps 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20200512151604_add_metadata_to_posts.rb: -------------------------------------------------------------------------------- 1 | class AddMetadataToPosts < ActiveRecord::Migration[5.0] 2 | def change 3 | add_column :posts, :metadata, :json 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # Note that this schema.rb definition is the authoritative source for your 6 | # database schema. If you need to create the application database on another 7 | # system, you should be using db:schema:load, not running all the migrations 8 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 9 | # you'll amass, the slower it'll run and the greater likelihood for issues). 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 20200512151604) do 14 | create_table "posts", force: :cascade do |t| 15 | t.integer "priority" 16 | t.decimal "rating" 17 | t.boolean "visible" 18 | t.string "title" 19 | t.text "body" 20 | t.date "expiration" 21 | t.time "hidden_after" 22 | t.datetime "published_at" 23 | t.datetime "created_at", null: false 24 | t.datetime "updated_at", null: false 25 | t.json "metadata" 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/procore/sift/8962e91f58b2ee4e602c42a87832e4fc3a8b0608/test/dummy/lib/assets/.keep -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/procore/sift/8962e91f58b2ee4e602c42a87832e4fc3a8b0608/test/dummy/log/.keep -------------------------------------------------------------------------------- /test/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/procore/sift/8962e91f58b2ee4e602c42a87832e4fc3a8b0608/test/dummy/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/procore/sift/8962e91f58b2ee4e602c42a87832e4fc3a8b0608/test/dummy/public/apple-touch-icon.png -------------------------------------------------------------------------------- /test/dummy/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/procore/sift/8962e91f58b2ee4e602c42a87832e4fc3a8b0608/test/dummy/public/favicon.ico -------------------------------------------------------------------------------- /test/dummy/test/controllers/posts_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class PostsControllerTest < ActionDispatch::IntegrationTest 4 | # test "the truth" do 5 | # assert true 6 | # end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/test/fixtures/posts.yml: -------------------------------------------------------------------------------- 1 | # Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html 2 | 3 | one: 4 | priority: 1 5 | rating: 9.99 6 | visible: false 7 | title: MyString 8 | body: MyText 9 | expiration: 2016-09-08 10 | hidden_after: 2016-09-08 18:43:04 11 | published_at: 2016-09-08 18:43:04 12 | 13 | two: 14 | priority: 1 15 | rating: 9.99 16 | visible: false 17 | title: MyString 18 | body: MyText 19 | expiration: 2016-09-08 20 | hidden_after: 2016-09-08 18:43:04 21 | published_at: 2016-09-08 18:43:04 22 | -------------------------------------------------------------------------------- /test/dummy/test/models/post_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class PostTest < ActiveSupport::TestCase 4 | # test "the truth" do 5 | # assert true 6 | # end 7 | end 8 | -------------------------------------------------------------------------------- /test/filter_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class FilterTest < ActiveSupport::TestCase 4 | test "it is initialized with the a param and a type" do 5 | filter = Sift::Filter.new("hi", :int, "hi", nil) 6 | 7 | assert_equal "hi", filter.param 8 | assert_equal :int, filter.type 9 | assert_equal "hi", filter.parameter.internal_name 10 | end 11 | 12 | test "it raises if the type is unknown" do 13 | assert_raise RuntimeError do 14 | Sift::Filter.new("hi", :foo, "hi", nil) 15 | end 16 | end 17 | 18 | test "it raises an exception if scope_params is not an array" do 19 | assert_raise ArgumentError do 20 | Sift::Filter.new("hi", :scope, "hi", nil, nil, {}) 21 | end 22 | end 23 | 24 | test "it raises an exception if scope_params does not contain symbols" do 25 | assert_raise ArgumentError do 26 | Sift::Filter.new("hi", :scope, "hi", nil, nil, ["foo"]) 27 | end 28 | end 29 | 30 | test "it knows what validation it needs when a datetime" do 31 | filter = Sift::Filter.new("hi", :datetime, "hi", nil) 32 | expected_validation = { format: { with: /\A.+(?:[^.]\.\.\.[^.]).+\z/, message: "must be a range" }, valid_date_range: true } 33 | 34 | assert_equal expected_validation, filter.validation(nil) 35 | end 36 | 37 | test "it knows what validation it needs when an int" do 38 | filter = Sift::Filter.new("hi", :int, "hi", nil) 39 | expected_validation = { valid_int: true } 40 | 41 | assert_equal expected_validation, filter.validation(nil) 42 | end 43 | 44 | test "it accepts a singular int or array of ints" do 45 | filter = Sift::Filter.new([1, 2], :int, [1, 2], nil) 46 | expected_validation = { valid_int: true } 47 | 48 | assert_equal expected_validation, filter.validation(nil) 49 | end 50 | 51 | test "it does not accept a mixed array when the type is int" do 52 | filter = Sift::Filter.new([1, 2, "a"], :int, [1, 2, "a"], nil) 53 | expected_validation = { valid_int: true } 54 | 55 | assert_equal expected_validation, filter.validation(nil) 56 | end 57 | 58 | test "it does not accept an empty array for type int" do 59 | filter = Sift::Filter.new([], :int, [], nil) 60 | expected_validation = { valid_int: true } 61 | 62 | assert_equal expected_validation, filter.validation(nil) 63 | end 64 | 65 | test "it knows what validation it needs when a decimal" do 66 | filter = Sift::Filter.new("hi", :decimal, "hi", nil) 67 | expected_validation = { numericality: true, allow_nil: true } 68 | 69 | assert_equal expected_validation, filter.validation(nil) 70 | end 71 | 72 | test "it knows what validation it needs when a boolean" do 73 | filter = Sift::Filter.new("hi", :boolean, "hi", nil) 74 | expected_validation = { inclusion: { in: [true, false] }, allow_nil: true } 75 | 76 | assert_equal expected_validation, filter.validation(nil) 77 | end 78 | 79 | test "it accepts a tap parameter" do 80 | filter = Sift::Filter.new("hi", :boolean, "hi", nil, nil, [], ->(_value, _params) { 81 | false 82 | }) 83 | 84 | assert_equal false, filter.instance_variable_get("@tap").call(true, {}) 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/filter_validator_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class FilterValidatorTest < ActiveSupport::TestCase 4 | test "it validates that integers are string integers" do 5 | filter = Sift::Filter.new(:hi, :int, :hi, nil) 6 | validator = Sift::FilterValidator.build( 7 | filters: [filter], 8 | sort_fields: [], 9 | filter_params: { hi: "1" }, 10 | sort_params: [], 11 | ) 12 | 13 | assert validator.valid? 14 | assert_equal Hash.new, validator.errors.messages 15 | end 16 | 17 | test "it validates that integers are numeric integers" do 18 | filter = Sift::Filter.new(:hola, :int, :hola, nil) 19 | validator = Sift::FilterValidator.build( 20 | filters: [filter], 21 | sort_fields: [], 22 | filter_params: { hola: 2 }, 23 | sort_params: [], 24 | ) 25 | 26 | assert validator.valid? 27 | assert_equal Hash.new, validator.errors.messages 28 | end 29 | 30 | test "it validates integers cannot be strings" do 31 | filter = Sift::Filter.new(:hi, :int, :hi, nil) 32 | expected_messages = { hi: ["must be integer, array of integers, or range"] } 33 | 34 | validator = Sift::FilterValidator.build( 35 | filters: [filter], 36 | sort_fields: [], 37 | filter_params: { hi: "hi123" }, 38 | sort_params: [], 39 | ) 40 | assert !validator.valid? 41 | assert_equal expected_messages, validator.errors.messages 42 | end 43 | 44 | test "it validates decimals are numerical" do 45 | filter = Sift::Filter.new(:hi, :decimal, :hi, nil) 46 | 47 | validator = Sift::FilterValidator.build( 48 | filters: [filter], 49 | sort_fields: [], 50 | filter_params: { hi: 2.13 }, 51 | sort_params: [], 52 | ) 53 | assert validator.valid? 54 | assert_equal Hash.new, validator.errors.messages 55 | end 56 | 57 | test "it validates decimals cannot be strings" do 58 | filter = Sift::Filter.new(:hi, :decimal, :hi, nil) 59 | expected_messages = { hi: ["is not a number"] } 60 | 61 | validator = Sift::FilterValidator.build( 62 | filters: [filter], 63 | sort_fields: [], 64 | filter_params: { hi: "123 hi" }, 65 | sort_params: [], 66 | ) 67 | assert !validator.valid? 68 | assert_equal expected_messages, validator.errors.messages 69 | end 70 | 71 | test "it validates booleans are 0 or 1" do 72 | filter = Sift::Filter.new(:hi, :boolean, :hi, nil) 73 | 74 | validator = Sift::FilterValidator.build( 75 | filters: [filter], 76 | sort_fields: [], 77 | filter_params: { hi: false }, 78 | sort_params: [], 79 | ) 80 | assert validator.valid? 81 | assert_equal Hash.new, validator.errors.messages 82 | end 83 | 84 | test "it validates multiple fields" do 85 | bool_filter = Sift::Filter.new(:hi, :boolean, :hi, nil) 86 | dec_filter = Sift::Filter.new(:bye, :decimal, :bye, nil) 87 | 88 | validator = Sift::FilterValidator.build( 89 | filters: [bool_filter, dec_filter], 90 | sort_fields: [], 91 | filter_params: { hi: true, bye: 1.24 }, 92 | sort_params: [], 93 | ) 94 | assert validator.valid? 95 | assert_equal Hash.new, validator.errors.messages 96 | end 97 | 98 | test "it invalidates when one of two filters is invalid" do 99 | bool_filter = Sift::Filter.new(:hi, :boolean, :hi, nil) 100 | dec_filter = Sift::Filter.new(:bye, :decimal, :bye, nil) 101 | expected_messages = { bye: ["is not a number"] } 102 | 103 | validator = Sift::FilterValidator.build( 104 | filters: [bool_filter, dec_filter], 105 | sort_fields: [], 106 | filter_params: { hi: "hi", bye: "whatup" }, 107 | sort_params: [], 108 | ) 109 | assert !validator.valid? 110 | assert_equal expected_messages, validator.errors.messages 111 | end 112 | 113 | test "it invalidates when both fields are invalid" do 114 | bool_filter = Sift::Filter.new(:hi, :date, :hi, nil) 115 | dec_filter = Sift::Filter.new(:bye, :decimal, :bye, nil) 116 | expected_messages = { hi: ["must be a range"], bye: ["is not a number"] } 117 | 118 | validator = Sift::FilterValidator.build( 119 | filters: [bool_filter, dec_filter], 120 | sort_fields: [], 121 | filter_params: { hi: 1, bye: "blue" }, 122 | sort_params: [], 123 | ) 124 | assert !validator.valid? 125 | assert_equal expected_messages, validator.errors.messages 126 | end 127 | 128 | test "it ignores validations for filters that are not being used" do 129 | bool_filter = Sift::Filter.new(:hi, :boolean, :hi, nil) 130 | dec_filter = Sift::Filter.new(:bye, :decimal, :bye, nil) 131 | 132 | validator = Sift::FilterValidator.build( 133 | filters: [bool_filter, dec_filter], 134 | sort_fields: [], 135 | filter_params: { hi: true }, 136 | sort_params: [], 137 | ) 138 | assert validator.valid? 139 | assert_equal Hash.new, validator.errors.messages 140 | end 141 | 142 | test "it allows ranges" do 143 | filter = Sift::Filter.new(:hi, :int, :hi, nil) 144 | 145 | validator = Sift::FilterValidator.build( 146 | filters: [filter], 147 | sort_fields: [], 148 | filter_params: { hi: "1..10" }, 149 | sort_params: [], 150 | ) 151 | assert validator.valid? 152 | assert_equal Hash.new, validator.errors.messages 153 | end 154 | 155 | test "datetimes are invalid unless they are a range" do 156 | filter = Sift::Filter.new(:hi, :datetime, :hi, nil) 157 | 158 | validator = Sift::FilterValidator.build( 159 | filters: [filter], 160 | sort_fields: [], 161 | filter_params: { hi: "2016-09-11T22:42:47Z...2016-09-11T22:42:47Z" }, 162 | sort_params: [], 163 | ) 164 | assert validator.valid? 165 | assert_equal Hash.new, validator.errors.messages 166 | end 167 | 168 | test "datetimes are invalid when not a range" do 169 | filter = Sift::Filter.new(:hi, :datetime, :hi, nil) 170 | expected_messages = { hi: ["must be a range"] } 171 | 172 | validator = Sift::FilterValidator.build( 173 | filters: [filter], 174 | sort_fields: [], 175 | filter_params: { hi: "2016-09-11T22:42:47Z" }, 176 | sort_params: [], 177 | ) 178 | assert !validator.valid? 179 | assert_equal expected_messages, validator.errors.messages 180 | end 181 | 182 | test "datetimes are invalid if any of the boundaries is invalid date" do 183 | filter = Sift::Filter.new(:hi, :datetime, :hi, nil) 184 | expected_messages = { hi: ["is invalid"] } 185 | 186 | validator = Sift::FilterValidator.build( 187 | filters: [filter], 188 | sort_fields: [], 189 | filter_params: { hi: "2016-09-11T22:42:47Z...invalid" }, 190 | sort_params: [], 191 | ) 192 | assert !validator.valid? 193 | assert_equal expected_messages, validator.errors.messages 194 | end 195 | 196 | test "it validates that sort exists" do 197 | filter = Sift::Sort.new(:hi, :datetime, :hi) 198 | expected_messages = { sort: ["is not included in the list"] } 199 | 200 | validator = Sift::FilterValidator.build( 201 | filters: [filter], 202 | sort_fields: [], 203 | filter_params: {}, 204 | sort_params: ["-hi"], 205 | ) 206 | assert !validator.valid? 207 | assert_equal expected_messages, validator.errors.messages 208 | end 209 | 210 | test "it respects a custom validation" do 211 | error_message = "super duper error message" 212 | filter = Sift::Filter.new(:hi, :int, :hi, nil, ->(validator) { 213 | validator.errors.add(:base, error_message) 214 | }) 215 | expected_messages = { base: [error_message] } 216 | 217 | validator = Sift::FilterValidator.build( 218 | filters: [filter], 219 | sort_fields: [], 220 | filter_params: { hi: 1 }, 221 | sort_params: [], 222 | ) 223 | assert !validator.valid? 224 | assert_equal expected_messages, validator.errors.messages 225 | end 226 | 227 | test "custom validation supercedes type validation" do 228 | filter = Sift::Filter.new(:hi, :int, :hi, nil, ->(validator) {}) 229 | 230 | validator = Sift::FilterValidator.build( 231 | filters: [filter], 232 | sort_fields: [], 233 | filter_params: { hi: "zebra" }, 234 | sort_params: [], 235 | ) 236 | assert validator.valid? 237 | assert_equal Hash.new, validator.errors.messages 238 | end 239 | 240 | test "it allows jsonb" do 241 | filter = Sift::Filter.new(:metadata, :jsonb, :metadata, nil) 242 | 243 | validator = Sift::FilterValidator.build( 244 | filters: [filter], 245 | sort_fields: [], 246 | filter_params: { metadata: "{\"a\":4}" }, 247 | sort_params: [], 248 | ) 249 | assert validator.valid? 250 | assert_equal Hash.new, validator.errors.messages 251 | end 252 | 253 | test "invalid jsonb when the value is not correct" do 254 | filter = Sift::Filter.new(:metadata, :jsonb, :metadata, nil) 255 | expected_messages = { metadata: ["must be a valid JSON"] } 256 | 257 | validator = Sift::FilterValidator.build( 258 | filters: [filter], 259 | sort_fields: [], 260 | filter_params: { metadata: "\"a\":4" }, 261 | sort_params: [], 262 | ) 263 | assert !validator.valid? 264 | assert_equal expected_messages, validator.errors.messages 265 | end 266 | end 267 | -------------------------------------------------------------------------------- /test/filtrator_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class FiltratorTest < ActiveSupport::TestCase 4 | test "it takes a collection, params and (optional) filters" do 5 | Sift::Filtrator.new(Post.all, { id: 1 }, []) 6 | end 7 | 8 | test "it filters by all the filters you pass it" do 9 | post = Post.create! 10 | filter = Sift::Filter.new(:id, :int, :id, nil) 11 | 12 | collection = Sift::Filtrator.filter( 13 | Post.all, 14 | { filters: { id: post.id } }, 15 | [filter], 16 | ) 17 | 18 | assert_equal Post.where(id: post.id), collection 19 | end 20 | 21 | test "it will not try to make a range out of a string field that includes ..." do 22 | post = Post.create!(title: "wow...man") 23 | filter = Sift::Filter.new(:title, :string, :title, nil) 24 | 25 | collection = Sift::Filtrator.filter( 26 | Post.all, 27 | { filters: { title: post.title } }, 28 | [filter], 29 | ) 30 | 31 | assert_equal Post.where(id: post.id).to_a, collection.to_a 32 | end 33 | 34 | test "it returns default when filter param not passed" do 35 | Post.create!(body: "foo") 36 | Post.create!(body: "bar") 37 | filter = Sift::Filter.new(:body2, :scope, :body2, ->(c) { c.order(:body) }) 38 | collection = Sift::Filtrator.filter(Post.all, {}, [filter]) 39 | 40 | assert_equal [Post.second, Post.first], collection.to_a 41 | end 42 | 43 | test "it will not return default if param passed" do 44 | Post.create!(body: "foo") 45 | filtered_post = Post.create!(body: "bar") 46 | filter = Sift::Filter.new(:body2, :scope, :body2, nil) 47 | collection = Sift::Filtrator.filter( 48 | Post.all, 49 | { filters: { body2: "bar" } }, 50 | [filter], 51 | ) 52 | 53 | assert_equal Post.where(id: filtered_post.id).to_a, collection.to_a 54 | end 55 | 56 | test "it can filter on scopes that need values from params" do 57 | Post.create!(priority: 5, expiration: "2017-01-01") 58 | Post.create!(priority: 5, expiration: "2017-01-02") 59 | Post.create!(priority: 7, expiration: "2020-10-20") 60 | filter = Sift::Filter.new( 61 | :expired_before_and_priority, 62 | :scope, 63 | :expired_before_and_priority, 64 | nil, 65 | nil, 66 | [:priority], 67 | ) 68 | collection = Sift::Filtrator.filter( 69 | Post.all, 70 | { filters: { expired_before_and_priority: "2017-12-31" }, priority: 5 }, 71 | [filter], 72 | ) 73 | 74 | assert_equal 3, Post.count 75 | assert_equal 2, Post.expired_before_and_priority("2017-12-31", priority: 5).count 76 | assert_equal 2, collection.count 77 | 78 | assert_equal( 79 | Post.expired_before_and_priority("2017-12-31", priority: 5).to_a, 80 | collection.to_a, 81 | ) 82 | end 83 | 84 | test "it can filter on scopes that need multiple values from params" do 85 | Post.create!(priority: 5, expiration: "2017-01-01") 86 | Post.create!(priority: 5, expiration: "2017-01-02") 87 | Post.create!(priority: 7, expiration: "2020-10-20") 88 | 89 | filter = Sift::Filter.new( 90 | :ordered_expired_before_and_priority, 91 | :scope, 92 | :ordered_expired_before_and_priority, 93 | nil, 94 | nil, 95 | [:date, :priority], 96 | ) 97 | collection = Sift::Filtrator.filter( 98 | Post.all, 99 | { 100 | filters: { ordered_expired_before_and_priority: "ASC" }, 101 | priority: 5, 102 | date: "2017-12-31" 103 | }, 104 | [filter], 105 | ) 106 | 107 | assert_equal 3, Post.count 108 | assert_equal 2, Post.ordered_expired_before_and_priority("ASC", date: "2017-12-31", priority: 5).count 109 | assert_equal 2, collection.count 110 | 111 | assert_equal Post.ordered_expired_before_and_priority("ASC", date: "2017-12-31", priority: 5).to_a, collection.to_a 112 | end 113 | 114 | test "it can sort on scopes that do not require arguments" do 115 | Post.create!(body: "zzzz") 116 | Post.create!(body: "aaaa") 117 | Post.create!(body: "ffff") 118 | sort = Sift::Sort.new(:body, :scope, :order_on_body_no_params) 119 | # scopes that take no param seem silly, as the user's designation of sort direction would be rendered useless 120 | # unless the controller does some sort of parsing on user's input and handles the sort on its own 121 | # nonetheless, Sift supports it :) 122 | collection = Sift::Filtrator.filter(Post.all, { sort: "-body" }, [sort]) 123 | 124 | assert_equal Post.order_on_body_no_params.to_a, collection.to_a 125 | end 126 | 127 | test "it can sort on scopes that require one argument" do 128 | Post.create!(body: "zzzz") 129 | Post.create!(body: "aaaa") 130 | Post.create!(body: "ffff") 131 | sort = Sift::Sort.new( 132 | :body, 133 | :scope, 134 | :order_on_body_one_param, 135 | [:direction], 136 | ) 137 | collection = Sift::Filtrator.filter(Post.all, { sort: "-body" }, [sort]) 138 | 139 | assert_equal Post.order_on_body_one_param(:desc).to_a, collection.to_a 140 | end 141 | 142 | test "it can sort on scopes that require multiple arguments" do 143 | Post.create!(body: "zzzz") 144 | Post.create!(body: "aaaa") 145 | Post.create!(body: "ffff") 146 | sort = Sift::Sort.new( 147 | :body, 148 | :scope, 149 | :order_on_body_multi_param, 150 | ["aaaa", :direction], 151 | ) 152 | collection = Sift::Filtrator.filter(Post.all, { sort: "-body" }, [sort]) 153 | 154 | assert_equal Post.order_on_body_multi_param("aaaa", :desc).to_a, collection.to_a 155 | end 156 | 157 | test "it can sort on scopes that are passed a lambda" do 158 | Post.create!(body: "zzzz") 159 | Post.create!(body: "aaaa") 160 | Post.create!(body: "ffff") 161 | sort = Sift::Sort.new( 162 | :body, 163 | :scope, 164 | :order_on_body_multi_param, 165 | [lambda { "aaaa" }, :direction], 166 | ) 167 | collection = Sift::Filtrator.filter(Post.all, { sort: "-body" }, [sort]) 168 | 169 | assert_equal Post.order_on_body_multi_param("aaaa", :desc).to_a, collection.to_a 170 | end 171 | 172 | test "it can sort on scopes that require multiple dynamic arguments" do 173 | Post.create!(body: "zzzz", expiration: "2017-01-01") 174 | Post.create!(body: "aaaa", expiration: "2017-01-01") 175 | Post.create!(body: "ffff", expiration: "2020-10-20") 176 | sort = Sift::Sort.new( 177 | :dynamic_sort, 178 | :scope, 179 | :expired_before_ordered_by_body, 180 | [:date, :direction], 181 | ) 182 | collection = Sift::Filtrator.filter( 183 | Post.all, 184 | { date: "2017-12-31", sort: "dynamic_sort", filters: {} }, 185 | [sort], 186 | ) 187 | 188 | assert_equal 3, Post.count 189 | assert_equal 2, Post.expired_before_ordered_by_body("2017-12-31", :asc).count 190 | assert Post.expired_before_ordered_by_body("2017-12-31", :asc).first.body == "aaaa" 191 | assert Post.expired_before_ordered_by_body("2017-12-31", :asc).last.body == "zzzz" 192 | 193 | assert_equal 2, collection.count 194 | 195 | assert collection.first.body == "aaaa" 196 | assert collection.last.body == "zzzz" 197 | 198 | assert_equal Post.expired_before_ordered_by_body("2017-12-31", :asc).to_a, collection.to_a 199 | end 200 | 201 | test "it can utilize the tap parameter to mutate a param" do 202 | Post.create!(priority: 5, expiration: "2017-01-01T00:00:00+00:00") 203 | Post.create!(priority: 5, expiration: "2017-01-02T00:00:00+00:00") 204 | Post.create!(priority: 7, expiration: "2020-10-20T00:00:00+00:00") 205 | 206 | filter = Sift::Filter.new( 207 | :expiration, 208 | :datetime, 209 | :expiration, 210 | nil, 211 | nil, 212 | [], 213 | ->(value, params) { 214 | if params[:mutate].present? 215 | "2017-01-02T00:00:00+00:00...2017-01-02T00:00:00+00:00" 216 | else 217 | value 218 | end 219 | }, 220 | ) 221 | collection = Sift::Filtrator.filter( 222 | Post.all, 223 | { 224 | filters: { expiration: "2017-01-01...2017-01-01" }, 225 | mutate: true 226 | }, 227 | [filter], 228 | ) 229 | 230 | assert_equal 3, Post.count 231 | assert_equal 1, collection.count 232 | 233 | assert_equal Post.where(expiration: "2017-01-02").to_a, collection.to_a 234 | end 235 | 236 | test "it can filter on scopes that need multiple values from params with a tap" do 237 | Post.create!(priority: 5, expiration: "2017-01-01") 238 | Post.create!(priority: 5, expiration: "2017-01-02") 239 | Post.create!(priority: 7, expiration: "2020-10-20") 240 | 241 | filter = Sift::Filter.new( 242 | :ordered_expired_before_and_priority, 243 | :scope, 244 | :ordered_expired_before_and_priority, 245 | nil, 246 | nil, 247 | [:date, :priority], 248 | ->(_value, _params) { 249 | "ASC" 250 | }, 251 | ) 252 | collection = Sift::Filtrator.filter( 253 | Post.all, 254 | { 255 | filters: { ordered_expired_before_and_priority: "DESC" }, 256 | priority: 5, 257 | date: "2017-12-31" 258 | }, 259 | [filter], 260 | ) 261 | 262 | assert_equal 3, Post.count 263 | assert_equal 2, Post.ordered_expired_before_and_priority("ASC", date: "2017-12-31", priority: 5).count 264 | assert_equal 2, collection.count 265 | 266 | assert_equal Post.ordered_expired_before_and_priority("ASC", date: "2017-12-31", priority: 5).to_a, collection.to_a 267 | end 268 | end 269 | -------------------------------------------------------------------------------- /test/sift_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SiftTest < ActiveSupport::TestCase 4 | class MyClass 5 | attr_writer :params 6 | include Sift 7 | 8 | def params 9 | @params ||= ActionController::Parameters.new({}) 10 | end 11 | end 12 | 13 | test "does nothing if no filters are registered" do 14 | MyClass.reset_filters 15 | assert_equal [], MyClass.new.filtrate(Post.all) 16 | end 17 | 18 | test "it registers filters with filter_on" do 19 | MyClass.reset_filters 20 | MyClass.filter_on(:id, type: :int) 21 | 22 | assert_equal [:id], MyClass.filters.map(&:param) 23 | end 24 | 25 | test "it registers sorts with sort_on" do 26 | MyClass.reset_filters 27 | MyClass.sort_on(:id, type: :int) 28 | 29 | assert_equal [:id], MyClass.filters.map(&:param) 30 | end 31 | 32 | test "it always allows sort parameters to flow through" do 33 | MyClass.reset_filters 34 | custom_sort = { sort: { attribute: "due_date", direction: "asc" } } 35 | my_class = MyClass.new 36 | my_class.params = custom_sort 37 | 38 | assert_equal [], my_class.filtrate(Post.all) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/sort_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SortTest < ActiveSupport::TestCase 4 | test "it is initialized with the a param and a type" do 5 | sort = Sift::Sort.new("hi", :int, "hi") 6 | 7 | assert_equal "hi", sort.param 8 | assert_equal :int, sort.type 9 | assert_equal "hi", sort.parameter.internal_name 10 | end 11 | 12 | test "it raises if the type is unknown" do 13 | assert_raise RuntimeError do 14 | Sift::Sort.new("hi", :foo, "hi") 15 | end 16 | end 17 | 18 | test "it raises if the scope params is not an array" do 19 | assert_raise RuntimeError do 20 | Sift::Sort.new("hi", :int, "hi", :direction) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Configure Rails Environment 2 | ENV["RAILS_ENV"] = "test" 3 | 4 | require File.expand_path("../test/dummy/config/environment.rb", __dir__) 5 | ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)] 6 | require "rails/test_help" 7 | require 'minitest/autorun' 8 | 9 | # Filter out Minitest backtrace while allowing backtrace from other libraries 10 | # to be shown. 11 | Minitest.backtrace_filter = Minitest::BacktraceFilter.new 12 | 13 | # Load fixtures from the engine 14 | if ActiveSupport::TestCase.respond_to?(:fixture_path=) 15 | ActiveSupport::TestCase.fixture_path = File.expand_path("fixtures", __dir__) 16 | ActionDispatch::IntegrationTest.fixture_path = ActiveSupport::TestCase.fixture_path 17 | ActiveSupport::TestCase.file_fixture_path = ActiveSupport::TestCase.fixture_path + "/files" 18 | ActiveSupport::TestCase.fixtures :all 19 | end 20 | -------------------------------------------------------------------------------- /test/type_validator_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class TypeValidatorTest < ActiveSupport::TestCase 4 | test "it does not accept a type that is not whitelisted" do 5 | validator = Sift::TypeValidator.new("test", :foo_bar) 6 | 7 | assert_equal false, validator.valid_type? 8 | end 9 | 10 | test "it accepts types that are whitelisted" do 11 | validator = Sift::TypeValidator.new("test", :string) 12 | 13 | assert_equal true, validator.valid_type? 14 | end 15 | 16 | test "it accepts arrays of integers for type int" do 17 | validator = Sift::TypeValidator.new([1, 2], :int) 18 | expected_validation = { valid_int: true } 19 | 20 | assert_equal expected_validation, validator.validate 21 | end 22 | 23 | test "it accepts a single integer for type int" do 24 | validator = Sift::TypeValidator.new(1, :int) 25 | expected_validation = { valid_int: true } 26 | 27 | assert_equal expected_validation, validator.validate 28 | end 29 | 30 | test "it accepts a range for type int" do 31 | validator = Sift::TypeValidator.new("1..10", :int) 32 | expected_validation = { valid_int: true } 33 | 34 | assert_equal expected_validation, validator.validate 35 | end 36 | 37 | test "it accepts a json array for type int" do 38 | validator = Sift::TypeValidator.new("[1,10]", :int) 39 | expected_validation = { valid_int: true } 40 | 41 | assert_equal expected_validation, validator.validate 42 | end 43 | 44 | test "it accepts a json array for type jsonb" do 45 | validator = Sift::TypeValidator.new("[1,10]", :jsonb) 46 | expected_validation = { valid_json: true } 47 | 48 | assert_equal expected_validation, validator.validate 49 | end 50 | 51 | test "it accepts a json object for type jsonb" do 52 | validator = Sift::TypeValidator.new("{\"a\":4}", :jsonb) 53 | expected_validation = { valid_json: true } 54 | 55 | assert_equal expected_validation, validator.validate 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/value_parser_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class FilterTest < ActiveSupport::TestCase 4 | test "returns the original value without options" do 5 | parser = Sift::ValueParser.new(value: "hi") 6 | 7 | assert_equal "hi", parser.parse 8 | end 9 | 10 | test "With options an array of integers results in an array of integers" do 11 | parser = Sift::ValueParser.new(value: [1, 2, 3]) 12 | 13 | assert_equal [1, 2, 3], parser.parse 14 | end 15 | 16 | test "With options a json string array of integers results in an array of integers" do 17 | options = { 18 | supports_ranges: true, 19 | supports_json: true 20 | } 21 | parser = Sift::ValueParser.new(value: "[1,2,3]", options: options) 22 | 23 | assert_equal [1, 2, 3], parser.parse 24 | end 25 | 26 | test "with invalid json returns original value" do 27 | options = { 28 | supports_ranges: true, 29 | supports_json: true 30 | } 31 | parser = Sift::ValueParser.new(value: "[1,2,3", options: options) 32 | 33 | assert_equal "[1,2,3", parser.parse 34 | end 35 | 36 | test "JSON parsing only supports arrays" do 37 | options = { 38 | supports_json: true 39 | } 40 | json_string = "{\"a\":4}" 41 | parser = Sift::ValueParser.new(value: json_string, options: options) 42 | 43 | assert_equal json_string, parser.parse 44 | end 45 | 46 | test "JSON parsing objects when supports_json_object is true" do 47 | options = { 48 | supports_json: true, 49 | supports_json_object: true 50 | } 51 | json_string = "{\"a\":\"abcd\"}" 52 | parser = Sift::ValueParser.new(value: json_string, options: options) 53 | parsed_expected = JSON.parse(json_string) 54 | 55 | assert_equal parsed_expected, parser.parse 56 | end 57 | 58 | test "JSON parsing objects when supports_json_object is true and the one json value is an array" do 59 | options = { 60 | supports_json: true, 61 | supports_json_object: true 62 | } 63 | json_string = "{\"a\":\"[1,2]\"}" 64 | parser = Sift::ValueParser.new(value: json_string, options: options) 65 | 66 | parsed_expected = { "a" => [1,2] } 67 | assert_equal parsed_expected, parser.parse 68 | end 69 | 70 | test "With options a range string of integers results in a range" do 71 | options = { 72 | supports_ranges: true, 73 | supports_json: true 74 | } 75 | parser = Sift::ValueParser.new(value: "1...3", options: options) 76 | 77 | assert_instance_of Range, parser.parse 78 | end 79 | 80 | test "parses true from 1" do 81 | options = { 82 | supports_boolean: true 83 | } 84 | parser = Sift::ValueParser.new(value: 1, options: options) 85 | 86 | assert_equal true, parser.parse 87 | end 88 | 89 | test "parses false from 0" do 90 | options = { 91 | supports_boolean: true 92 | } 93 | parser = Sift::ValueParser.new(value: 0, options: options) 94 | 95 | assert_equal false, parser.parse 96 | end 97 | 98 | test "parses range for range values" do 99 | options = { 100 | supports_ranges: true 101 | } 102 | test_sets = [ 103 | { 104 | type: :date, 105 | start_value: '2008-06-21', 106 | end_value: '2008-06-22' 107 | }, 108 | { 109 | type: :time, 110 | start_value: '13:30:00', 111 | end_value: '13:45:00' 112 | }, 113 | { 114 | type: :boolean, 115 | start_value: true, 116 | end_value: false 117 | }, 118 | { 119 | type: :int, 120 | start_value: 3, 121 | end_value: 20 122 | }, 123 | { 124 | type: :decimal, 125 | start_value: 123.456, 126 | end_value: 44.55 127 | }, 128 | { 129 | start_value: 'any', 130 | end_value: 'value' 131 | } 132 | ] 133 | 134 | test_sets.each do |set| 135 | range_string = "#{set[:start_value]}...#{set[:end_value]}" 136 | parser = Sift::ValueParser.new(value: range_string, type: set[:type], options: options) 137 | 138 | result = parser.parse 139 | assert_instance_of Range, result 140 | assert_equal result.last, set[:end_value].to_s 141 | end 142 | end 143 | 144 | test "parses range for Date string range and normalizes DateTime values" do 145 | options = { 146 | supports_ranges: true 147 | } 148 | 149 | start_date = "2018-01-01T10:00:00Z[Etc/UTC]" 150 | end_date = "2018-01-01T12:00:00Z[Etc/UTC]" 151 | range_string = "#{start_date}...#{end_date}" 152 | parser = Sift::ValueParser.new(value: range_string, type: :datetime, options: options) 153 | 154 | result = parser.parse 155 | assert_instance_of Range, result 156 | assert_equal DateTime.parse(result.last), DateTime.parse(end_date) 157 | end 158 | end 159 | --------------------------------------------------------------------------------