From 21b42e4211f6ebb5b63d3a3ed05e353ffb7f1872 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Mon, 13 Jan 2025 10:37:42 +0100 Subject: [PATCH 001/104] feat(mongo): add datasource mongo package --- .rubocop.yml | 19 +- .../.gitignore | 15 + .../forest_admin_datasource_mongoid/.rspec | 3 + .../forest_admin_datasource_mongoid/Gemfile | 18 + .../Gemfile-test | 16 + .../forest_admin_datasource_mongoid/LICENSE | 674 ++++++++++++++++++ .../forest_admin_datasource_mongoid/Rakefile | 12 + .../bin/console | 11 + .../forest_admin_datasource_mongoid/bin/setup | 8 + .../forest_admin_datasource_mongoid.gemspec | 38 + .../lib/forest_admin_datasource_mongoid.rb | 11 + .../collection.rb | 71 ++ .../datasource.rb | 25 + .../parser/column.rb | 81 +++ .../parser/relation.rb | 46 ++ .../utils/mongoid_serializer.rb | 39 + .../version.rb | 3 + .../forest_admin_datasource_mongoid_spec.rb | 5 + .../spec/spec_helper.rb | 44 ++ 19 files changed, 1138 insertions(+), 1 deletion(-) create mode 100644 packages/forest_admin_datasource_mongoid/.gitignore create mode 100644 packages/forest_admin_datasource_mongoid/.rspec create mode 100644 packages/forest_admin_datasource_mongoid/Gemfile create mode 100644 packages/forest_admin_datasource_mongoid/Gemfile-test create mode 100644 packages/forest_admin_datasource_mongoid/LICENSE create mode 100644 packages/forest_admin_datasource_mongoid/Rakefile create mode 100755 packages/forest_admin_datasource_mongoid/bin/console create mode 100755 packages/forest_admin_datasource_mongoid/bin/setup create mode 100644 packages/forest_admin_datasource_mongoid/forest_admin_datasource_mongoid.gemspec create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid.rb create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/version.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/forest_admin_datasource_mongoid_spec.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/spec_helper.rb diff --git a/.rubocop.yml b/.rubocop.yml index f1253a4ab..014910c88 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,6 +7,7 @@ AllCops: NewCops: enable Exclude: - 'packages/forest_admin_datasource_active_record/spec/dummy/**/*' + - 'packages/forest_admin_datasource_mongoid/spec/dummy/**/*' - 'packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer_override.rb' - 'node_modules/semantic-release-rubygem/**/*' @@ -16,6 +17,7 @@ Gemspec/OrderedDependencies: - 'packages/forest_admin_agent/forest_admin_agent.gemspec' - 'packages/forest_admin_rails/forest_admin_rails.gemspec' - 'packages/forest_admin_datasource_toolkit/forest_admin_datasource_toolkit.gemspec' + - 'packages/forest_admin_datasource_mongoid/forest_admin_datasource_mongoid.gemspec' - 'packages/forest_admin_test_toolkit/forest_admin_test_toolkit.gemspec' - 'packages/forest_admin_datasource_customizer/forest_admin_datasource_customizer.gemspec' - 'packages/forest_admin_datasource_active_record/forest_admin_datasource_active_record.gemspec' @@ -26,6 +28,7 @@ Gemspec/RequireMFA: - 'packages/forest_admin_agent/forest_admin_agent.gemspec' - 'packages/forest_admin_rails/forest_admin_rails.gemspec' - 'packages/forest_admin_datasource_toolkit/forest_admin_datasource_toolkit.gemspec' + - 'packages/forest_admin_datasource_mongoid/forest_admin_datasource_mongoid.gemspec' - 'packages/forest_admin_test_toolkit/forest_admin_test_toolkit.gemspec' - 'packages/forest_admin_datasource_customizer/forest_admin_datasource_customizer.gemspec' - 'packages/forest_admin_datasource_active_record/forest_admin_datasource_active_record.gemspec' @@ -37,6 +40,7 @@ Lint/PercentStringArray: - 'packages/forest_admin_agent/forest_admin_agent.gemspec' - 'packages/forest_admin_rails/forest_admin_rails.gemspec' - 'packages/forest_admin_datasource_toolkit/forest_admin_datasource_toolkit.gemspec' + - 'packages/forest_admin_datasource_mongoid/forest_admin_datasource_mongoid.gemspec' - 'packages/forest_admin_datasource_customizer/forest_admin_datasource_customizer.gemspec' - 'packages/forest_admin_datasource_active_record/forest_admin_datasource_active_record.gemspec' @@ -60,6 +64,7 @@ Style/BlockComments: - 'packages/forest_admin_agent/spec/spec_helper.rb' - 'packages/forest_admin_rails/spec/spec_helper.rb' - 'packages/forest_admin_datasource_toolkit/spec/spec_helper.rb' + - 'packages/forest_admin_datasource_mongoid/spec/spec_helper.rb' - 'packages/forest_admin_datasource_customizer/spec/spec_helper.rb' - 'packages/forest_admin_datasource_active_record/spec/spec_helper.rb' @@ -86,6 +91,7 @@ Style/MutableConstant: - 'packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/schema_emitter.rb' - 'packages/forest_admin_rails/lib/forest_admin_rails/version.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/version.rb' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/version.rb' - 'packages/forest_admin_test_toolkit/lib/forest_admin_test_toolkit/version.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/version.rb' - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/version.rb' @@ -127,7 +133,14 @@ Style/StringLiterals: - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/version.rb' - 'packages/forest_admin_datasource_toolkit/spec/spec_helper.rb' - - 'packages/forest_admin_datasource_toolkit/forest_admin_test_toolkit.gemspec' + - 'packages/forest_admin_datasource_mongoid/forest_admin_datasource_mongoid.gemspec' + - 'packages/forest_admin_datasource_mongoid/Gemfile' + - 'packages/forest_admin_datasource_mongoid/Rakefile' + - 'packages/forest_admin_datasource_mongoid/bin/console' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid.rb' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/version.rb' + - 'packages/forest_admin_datasource_mongoid/spec/spec_helper.rb' + - 'packages/forest_admin_test_toolkit/forest_admin_test_toolkit.gemspec' - 'packages/forest_admin_test_toolkit/Gemfile' - 'packages/forest_admin_test_toolkit/Rakefile' - 'packages/forest_admin_test_toolkit/bin/console' @@ -208,6 +221,7 @@ Metrics/ModuleLength: - 'packages/forest_admin_agent/spec/**/*' - 'packages/forest_admin_datasource_active_record/spec/**/*' - 'packages/forest_admin_datasource_toolkit/spec/**/*' + - 'packages/forest_admin_datasource_mongoid/spec/**/*' - 'packages/forest_admin_datasource_customizer/spec/**/*' - 'packages/forest_admin_rails/spec/**/*' @@ -273,6 +287,8 @@ Metrics/ClassLength: - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb' - 'packages/forest_admin_agent/lib/forest_admin_agent/utils/schema/generator_action.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator.rb' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb' Style/OpenStructUse: Exclude: @@ -292,6 +308,7 @@ Layout/LineLength: - 'packages/forest_admin_agent/spec/**/*' - 'packages/forest_admin_datasource_active_record/spec/**/*' - 'packages/forest_admin_datasource_toolkit/spec/**/*' + - 'packages/forest_admin_datasource_mongoid/spec/**/*' - 'packages/forest_admin_datasource_customizer/spec/**/*' - 'packages/forest_admin_rails/spec/**/*' - 'packages/forest_admin_agent/lib/forest_admin_agent/http/forest_admin_api_requester.rb' diff --git a/packages/forest_admin_datasource_mongoid/.gitignore b/packages/forest_admin_datasource_mongoid/.gitignore new file mode 100644 index 000000000..971206634 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/.gitignore @@ -0,0 +1,15 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +/spec/dummy/tmp/ +/spec/dummy/log/ +/spec/dummy/db/*.db + +# rspec failure tracking +.rspec_status diff --git a/packages/forest_admin_datasource_mongoid/.rspec b/packages/forest_admin_datasource_mongoid/.rspec new file mode 100644 index 000000000..34c5164d9 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/.rspec @@ -0,0 +1,3 @@ +--format documentation +--color +--require spec_helper diff --git a/packages/forest_admin_datasource_mongoid/Gemfile b/packages/forest_admin_datasource_mongoid/Gemfile new file mode 100644 index 000000000..9c25bd32d --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/Gemfile @@ -0,0 +1,18 @@ +source "https://rubygems.org" + +# Specify your gem's dependencies in forest_admin_datasource_mongoid.gemspec +gemspec + +gem 'mongoid' + +gem 'forest_admin_datasource_toolkit' +gem 'rake', '~> 13.0' +gem 'rubocop', '~> 1.21' + +group :development, :test do + gem 'rails' + gem 'rspec-rails', '~> 3.0' + gem 'simplecov', '~> 0.22', require: false + gem 'simplecov-html', '~> 0.12.3' + gem 'simplecov_json_formatter', '~> 0.1.4' +end diff --git a/packages/forest_admin_datasource_mongoid/Gemfile-test b/packages/forest_admin_datasource_mongoid/Gemfile-test new file mode 100644 index 000000000..88cb7966a --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/Gemfile-test @@ -0,0 +1,16 @@ +source "https://rubygems.org" + +# Specify your gem's dependencies in forest_admin_datasource_mongoid.gemspec +gemspec + +gem 'rake', '~> 13.0' +gem 'rubocop', '~> 1.21' + +group :development, :test do + gem 'forest_admin_datasource_toolkit', path: '../forest_admin_datasource_toolkit' + gem 'rails' + gem 'rspec-rails', '~> 3.0' + gem 'simplecov', '~> 0.22', require: false + gem 'simplecov-html', '~> 0.12.3' + gem 'simplecov_json_formatter', '~> 0.1.4' +end diff --git a/packages/forest_admin_datasource_mongoid/LICENSE b/packages/forest_admin_datasource_mongoid/LICENSE new file mode 100644 index 000000000..9cecc1d46 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {one line to give the program's name and a brief idea of what it does.} + Copyright (C) {year} {name of author} + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + {project} Copyright (C) {year} {fullname} + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/packages/forest_admin_datasource_mongoid/Rakefile b/packages/forest_admin_datasource_mongoid/Rakefile new file mode 100644 index 000000000..cca717544 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/Rakefile @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +require "rubocop/rake_task" + +RuboCop::RakeTask.new + +task default: %i[spec rubocop] diff --git a/packages/forest_admin_datasource_mongoid/bin/console b/packages/forest_admin_datasource_mongoid/bin/console new file mode 100755 index 000000000..f173ab353 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/bin/console @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "forest_admin_datasource_mongoid" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +require "irb" +IRB.start(__FILE__) diff --git a/packages/forest_admin_datasource_mongoid/bin/setup b/packages/forest_admin_datasource_mongoid/bin/setup new file mode 100755 index 000000000..dce67d860 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/packages/forest_admin_datasource_mongoid/forest_admin_datasource_mongoid.gemspec b/packages/forest_admin_datasource_mongoid/forest_admin_datasource_mongoid.gemspec new file mode 100644 index 000000000..7b7c59eb1 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/forest_admin_datasource_mongoid.gemspec @@ -0,0 +1,38 @@ +lib = File.expand_path('lib', __dir__) +$LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib) + +require_relative "lib/forest_admin_datasource_mongoid/version" + +Gem::Specification.new do |spec| + spec.name = "forest_admin_datasource_mongoid" + spec.version = ForestAdminDatasourceMongoid::VERSION + spec.authors = ["Matthieu", "Nicolas"] + spec.email = ["matthv@gmail.com", "nicolasalexandre9@gmail.com"] + spec.homepage = "https://www.forestadmin.com" + spec.summary = "Ruby agent for Forest Admin." + spec.description = "Forest is a modern admin interface that works on all major web frameworks. This gem makes Forest +admin work on any Ruby application." + spec.license = "GPL-3.0" + spec.required_ruby_version = ">= 3.0.0" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "https://github.com/ForestAdmin/agent-ruby" + spec.metadata["changelog_uri"] = "https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md" + spec.metadata["rubygems_mfa_required"] = "false" + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + spec.files = Dir.chdir(__dir__) do + `git ls-files -z`.split("\x0").reject do |f| + (File.expand_path(f) == __FILE__) || + f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile]) + end + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_dependency "mongoid", ">= 9.0" + spec.add_dependency "activesupport", ">= 6.1" + spec.add_dependency "zeitwerk", "~> 2.3" +end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid.rb new file mode 100644 index 000000000..fde794b0c --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid.rb @@ -0,0 +1,11 @@ +require_relative 'forest_admin_datasource_mongoid/version' +# require 'forest_admin_datasource_toolkit' +require 'zeitwerk' + +loader = Zeitwerk::Loader.for_gem +loader.setup + +module ForestAdminDatasourceMongoid + class Error < StandardError; end + # Your code goes here... +end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb new file mode 100644 index 000000000..75d57f7f2 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -0,0 +1,71 @@ +module ForestAdminDatasourceMongoid + class Collection < ForestAdminDatasourceToolkit::Collection + include Parser::Column + # include Parser::Relation + include ForestAdminDatasourceToolkit::Components::Query + + attr_reader :model + + def initialize(datasource, model) + @model = model + name = format_model_name(@model.name) + super(datasource, name) + + fetch_fields + # fetch_associations + # enable_count + end + + def list(_caller, _filter, projection) + # query = Utils::Query.new(self, projection, filter) + + @model.all.map { |record| Utils::MongoidSerializer.new(record).to_hash(projection) } + end + + def aggregate(_caller, filter, aggregation, limit = nil) + # Utils::QueryAggregate.new(self, aggregation, filter, limit).get + end + + def create(_caller, data) + # Utils::ActiveRecordSerializer.new(@model.create(data)).to_hash(ProjectionFactory.all(self)) + end + + def update(_caller, filter, data) + # entity = Utils::Query.new(self, nil, filter).build.first + # entity&.update(data) + end + + def delete(_caller, filter) + # entities = Utils::Query.new(self, nil, filter).build + # entities&.each(&:destroy) + end + + private + + def format_model_name(class_name) + class_name.gsub('::', '__') + end + + def fetch_fields + @model.fields.each do |column_name, column| + field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( + column_type: get_column_type(column), + filter_operators: operators_for_column_type(get_column_type(column)), + is_primary_key: column.object_id_field?, + is_read_only: false, + is_sortable: true, + default_value: column.object_id_field? ? nil : get_default_value(column), + enum_values: get_enum_values(column), + # validations: get_validations(column) + validations: [] + ) + + add_field(column_name, field) + end + end + + def fetch_associations + # TODO + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb new file mode 100644 index 000000000..284830672 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb @@ -0,0 +1,25 @@ +require 'mongo' +require 'mongoid' + +module ForestAdminDatasourceMongoid + class Datasource < ForestAdminDatasourceToolkit::Datasource + attr_reader :models + + def initialize + super + generate + end + + private + + def generate + models = ObjectSpace.each_object(Class).select do |klass| + klass < Mongoid::Document && !klass.name.start_with?('Mongoid::') + end + + models.each do |model| + add_collection(Collection.new(self, model)) + end + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb new file mode 100644 index 000000000..460d38ef6 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb @@ -0,0 +1,81 @@ +module ForestAdminDatasourceMongoid + module Parser + module Column + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + + TYPES = { + 'Array' => 'Json', + 'BSON::Binary' => 'Binary', + 'BigDecimal' => 'Number', + 'Mongoid::Boolean' => 'Boolean', + 'Date' => 'Date', + 'DateTime' => 'Date', + 'Float' => 'Number', + 'Hash' => 'Json', + 'Integer' => 'Number', + 'Object' => 'Json', + 'BSON::ObjectId' => 'String', + 'Range' => 'Json', + 'Regexp' => 'String', + 'Set' => 'Json', + 'String' => 'String', + 'Mongoid::StringifiedSymbol' => 'String', + 'Symbol' => 'String', + 'Time' => 'Date', + 'ActiveSupport::TimeWithZone' => 'Date' + }.freeze + + def get_column_type(column) + # if model.respond_to?(:defined_enums) && + # model.defined_enums.key?(column.name) + # return 'Enum' + # end + + # is_array = column.respond_to?(:array) && column.array == true + # is_array ? "[#{TYPES[column.type]}]" : TYPES[column.type] + TYPES[column.type.to_s] || 'String' + end + + def get_default_value(column) + if column.options.key?(:default) + default = column.options[:default] + + return default.respond_to?(:call) ? default.call : default + end + + nil + end + + def get_enum_values(_column) + [] + # if get_column_type(model, column) == 'Enum' + # if sti_column?(model, column) + # model.descendants.each { |sti_model| enum_values << sti_model.name } + # else + # model.defined_enums[column.name].each_key { |name| enum_values << name } + # end + # end + end + + def operators_for_column_type(type) + result = [Operators::PRESENT, Operators::MISSING] + equality = [Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN] + + if type.is_a? String + orderables = [Operators::LESS_THAN, Operators::GREATER_THAN] + strings = [Operators::LIKE, Operators::I_LIKE, Operators::NOT_CONTAINS] + + result += equality if %w[Boolean Binary Enum Uuid].include?(type) + + result = result + equality + orderables if %w[Date Dateonly Number].include?(type) + + result = result + equality + orderables + strings if %w[String].include?(type) + end + + result = result + equality + ['Includes_All'] if type.is_a? Array + + result + end + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb new file mode 100644 index 000000000..cf9d38739 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb @@ -0,0 +1,46 @@ +module ForestAdminDatasourceMongoid + module Parser + module Relation + # TODO + + # def associations(model, support_polymorphic_relations: false) + # model.reflect_on_all_associations.select do |association| + # is_valid_association = !get_class(association).nil? && !active_type?(get_class(association)) + # if support_polymorphic_relations + # polymorphic?(association) ? true : is_valid_association + # else + # !polymorphic?(association) && is_valid_association + # end + # end + # end + # + # def polymorphic?(association) + # (association.options.key?(:polymorphic) && association.options[:polymorphic]) || + # association.inverse_of&.polymorphic? + # end + # + # def get_class(association) + # association.klass + # rescue StandardError + # nil + # end + # + # # NOTICE: Ignores ActiveType::Object association during introspection and interactions. + # # See the gem documentation: https://github.com/makandra/active_type + # def active_type?(model) + # Object.const_defined?('ActiveType::Object') && model < ActiveType::Object + # end + # + # def get_polymorphic_types(relation) + # types = {} + # @datasource.models.each do |model| + # unless model.reflect_on_all_associations.none? { |assoc| assoc.options[:as] == relation.name.to_sym } + # types[format_model_name(model.name)] = model.primary_key + # end + # end + # + # types + # end + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb new file mode 100644 index 000000000..b3e3eff50 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb @@ -0,0 +1,39 @@ +module ForestAdminDatasourceMongoid + module Utils + MongoidSerializer = Struct.new(:object) do + def to_hash(projection = nil) + hash_object(object, projection) + end + + def hash_object(object, _projection = nil, with_associations: true) + hash = {} + + return if object.nil? + + object.attributes.each do |key, value| + hash[key] = value + end + # hash.merge! object.attributes + + if with_associations + # each_association_collection(object, projection) do |association_name, item| + # hash[association_name] = hash_object( + # item, + # projection.relations[association_name], + # with_associations: projection.relations.key?(association_name) + # ) + # end + end + + hash + end + + # def each_association_collection(object, projection) + # one_associations = %i[has_one belongs_to] + # object.class.reflect_on_all_associations + # .filter { |a| one_associations.include?(a.macro) && projection.relations.key?(a.name.to_s) } + # .each { |association| yield(association.name.to_s, object.send(association.name.to_s)) } + # end + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/version.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/version.rb new file mode 100644 index 000000000..8e6d4bf3c --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/version.rb @@ -0,0 +1,3 @@ +module ForestAdminDatasourceMongoid + VERSION = "1.0.0-beta.89" +end diff --git a/packages/forest_admin_datasource_mongoid/spec/forest_admin_datasource_mongoid_spec.rb b/packages/forest_admin_datasource_mongoid/spec/forest_admin_datasource_mongoid_spec.rb new file mode 100644 index 000000000..473d58472 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/forest_admin_datasource_mongoid_spec.rb @@ -0,0 +1,5 @@ +RSpec.describe ForestAdminDatasourceMongoid do + it 'has a version number' do + expect(described_class::VERSION).not_to be_nil + end +end diff --git a/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb b/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb new file mode 100644 index 000000000..e46fda326 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb @@ -0,0 +1,44 @@ +require 'simplecov' +require 'simplecov_json_formatter' +require 'simplecov-html' +require 'forest_admin_datasource_mongoid' +require 'active_record' +require 'database_cleaner-active_record' + +SimpleCov.formatters = [SimpleCov::Formatter::JSONFormatter, SimpleCov::Formatter::HTMLFormatter] +SimpleCov.start do + add_filter 'spec' +end + +ENV['RAILS_ENV'] ||= 'test' +require File.expand_path('dummy/config/environment', __dir__) +Rails.application.eager_load! + +ActiveRecord::Migration.verbose = false +ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: 'db/database.db') +ActiveRecord::MigrationContext.new("spec/dummy/db/migrate").migrate + +RSpec.configure do |config| + config.example_status_persistence_file_path = ".rspec_status" + + config.expect_with :rspec do |c| + c.syntax = :expect + end + + # database_cleaner config + config.before(:suite) do + DatabaseCleaner.clean_with :truncation, except: %w[ar_internal_metadata] + end + + config.before do + DatabaseCleaner.strategy = :transaction + end + + config.before do + DatabaseCleaner.start + end + + config.after do + DatabaseCleaner.clean + end +end From 5e65c54a569335c46cee55ee075474a4a8bb4047 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Mon, 13 Jan 2025 17:42:44 +0100 Subject: [PATCH 002/104] feat(mongo): parse field validations --- .../collection.rb | 6 +- .../parser/column.rb | 11 --- .../parser/validation.rb | 81 +++++++++++++++++++ 3 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/validation.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index 75d57f7f2..e746c108f 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -1,6 +1,7 @@ module ForestAdminDatasourceMongoid class Collection < ForestAdminDatasourceToolkit::Collection include Parser::Column + include Parser::Validation # include Parser::Relation include ForestAdminDatasourceToolkit::Components::Query @@ -55,9 +56,8 @@ def fetch_fields is_read_only: false, is_sortable: true, default_value: column.object_id_field? ? nil : get_default_value(column), - enum_values: get_enum_values(column), - # validations: get_validations(column) - validations: [] + enum_values: [], + validations: get_validations(column) ) add_field(column_name, field) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb index 460d38ef6..45300fcb8 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb @@ -46,17 +46,6 @@ def get_default_value(column) nil end - def get_enum_values(_column) - [] - # if get_column_type(model, column) == 'Enum' - # if sti_column?(model, column) - # model.descendants.each { |sti_model| enum_values << sti_model.name } - # else - # model.defined_enums[column.name].each_key { |name| enum_values << name } - # end - # end - end - def operators_for_column_type(type) result = [Operators::PRESENT, Operators::MISSING] equality = [Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN] diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/validation.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/validation.rb new file mode 100644 index 000000000..a87735b5b --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/validation.rb @@ -0,0 +1,81 @@ +module ForestAdminDatasourceMongoid + module Parser + module Validation + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + def get_validations(column) + validations = [] + # NOTICE: Do not consider validations if a before_validation Active Records + # Callback is detected. + default_callback_excluded = [:normalize_changed_in_place_attributes] + return validations if @model._validation_callbacks + .reject { |callback| default_callback_excluded.include?(callback.filter) } + .map(&:kind).include?(:before) + + if @model._validators? && @model._validators[column.name.to_sym].size.positive? + @model._validators[column.name.to_sym].each do |validator| + # NOTICE: Do not consider conditional validations + next if validator.options[:if] || validator.options[:unless] || validator.options[:on] + + case validator.class.to_s + when Mongoid::Validatable::PresenceValidator.to_s + validations << { operator: Operators::PRESENT } + when ActiveModel::Validations::NumericalityValidator.to_s + validations = parse_numericality_validator(validator, validations) + when Mongoid::Validatable::LengthValidator.to_s + validations = parse_length_validator(validator, validations) + when Mongoid::Validatable::FormatValidator.to_s + validations = parse_format_validator(validator, validations) + end + end + end + + validations + end + + def parse_numericality_validator(validator, parsed_validations) + validator.options.each do |option, value| + case option + when :greater_than, :greater_than_or_equal_to + parsed_validations << { operator: Operators::GREATER_THAN, value: value } + when :less_than, :less_than_or_equal_to + parsed_validations << { operator: Operators::LESS_THAN, value: value } + end + end + end + + def parse_length_validator(validator, parsed_validations) + return unless get_column_type(column) == 'String' + + validator.options.each do |option, value| + case option + when :minimum + parsed_validations << { operator: Operators::LONGER_THAN, value: value } + when :maximum + parsed_validations << { operator: Operators::SHORTER_THAN, value: value } + when :is + parsed_validations << { operator: Operators::LONGER_THAN, value: value } + parsed_validations << { operator: Operators::SHORTER_THAN, value: value } + end + end + end + + def parse_format_validator(validator, parsed_validations) + validator.options.each do |option, value| + case option + when :with + options = /\?([imx]){0,3}/.match(validator.options[:with].to_s) + options = options && options[1] ? options[1] : '' + regex = value.source + + # NOTICE: Transform a Ruby regex into a JS one + regex = regex.sub('\\A', '^').sub('\\Z', '$').sub('\\z', '$').gsub(/\n+|\s+/, '') + + parsed_validations << { operator: Operators::CONTAINS, value: "/#{regex}/#{options}" } + end + end + + parsed_validations + end + end + end +end From ae83b4547a0133bf860d10e703ceb8364606edbd Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Mon, 13 Jan 2025 17:57:27 +0100 Subject: [PATCH 003/104] fix(validation): fix field validation parser on active record datasource --- .../parser/validation.rb | 48 ++++--------------- 1 file changed, 9 insertions(+), 39 deletions(-) diff --git a/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/parser/validation.rb b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/parser/validation.rb index b8949c592..2013ad5dc 100644 --- a/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/parser/validation.rb +++ b/packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/parser/validation.rb @@ -1,6 +1,7 @@ module ForestAdminDatasourceActiveRecord module Parser module Validation + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree def get_validations(column) validations = [] # NOTICE: Do not consider validations if a before_validation Active Records @@ -14,10 +15,7 @@ def get_validations(column) case validator when ActiveRecord::Validations::PresenceValidator - validations << { - type: 'is present', - message: validator.options[:message] - } + validations << { operator: Operators::PRESENT } when ActiveModel::Validations::NumericalityValidator validations = parse_numericality_validator(validator, validations) when ActiveModel::Validations::LengthValidator @@ -35,17 +33,9 @@ def parse_numericality_validator(validator, parsed_validations) validator.options.each do |option, value| case option when :greater_than, :greater_than_or_equal_to - parsed_validations << { - type: 'is greater than', - value: value, - message: validator.options[:message] - } + parsed_validations << { operator: Operators::GREATER_THAN, value: value } when :less_than, :less_than_or_equal_to - parsed_validations << { - type: 'is less than', - value: value, - message: validator.options[:message] - } + parsed_validations << { operator: Operators::LESS_THAN, value: value } end end end @@ -56,28 +46,12 @@ def parse_length_validator(validator, parsed_validations) validator.options.each do |option, value| case option when :minimum - parsed_validations << { - type: 'is longer than', - value: value, - message: validator.options[:message] - } + parsed_validations << { operator: Operators::LONGER_THAN, value: value } when :maximum - parsed_validations << { - type: 'is shorter than', - value: value, - message: validator.options[:message] - } + parsed_validations << { operator: Operators::SHORTER_THAN, value: value } when :is - parsed_validations << { - type: 'is longer than', - value: value, - message: validator.options[:message] - } - parsed_validations << { - type: 'is shorter than', - value: value, - message: validator.options[:message] - } + parsed_validations << { operator: Operators::LONGER_THAN, value: value } + parsed_validations << { operator: Operators::SHORTER_THAN, value: value } end end end @@ -93,11 +67,7 @@ def parse_format_validator(validator, parsed_validations) # NOTICE: Transform a Ruby regex into a JS one regex = regex.sub('\\A', '^').sub('\\Z', '$').sub('\\z', '$').gsub(/\n+|\s+/, '') - parsed_validations << { - type: 'is like', - value: "/#{regex}/#{options}", - message: validator.options[:message] - } + parsed_validations << { operator: Operators::CONTAINS, value: "/#{regex}/#{options}" } end end From 63aee33da095d69126ee9b73dc62151d598bbf6e Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 14 Jan 2025 17:48:45 +0100 Subject: [PATCH 004/104] feat(mongo): add relations --- .rubocop.yml | 2 + .../collection.rb | 66 ++++++++++++++++++- .../datasource.rb | 65 ++++++++++++++++++ .../parser/relation.rb | 22 +++++++ .../through_collection.rb | 54 +++++++++++++++ 5 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/through_collection.rb diff --git a/.rubocop.yml b/.rubocop.yml index 014910c88..6715e7c79 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -253,12 +253,14 @@ Metrics/MethodLength: - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/rename_field/rename_field_collection_decorator.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/form_factory.rb' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb' Metrics/BlockLength: Exclude: - 'packages/forest_admin_datasource_active_record/lib/forest_admin_datasource_active_record/collection.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter_factory.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/base_action.rb' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb' Metrics/ClassLength: Exclude: diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index e746c108f..66c744e74 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -13,7 +13,7 @@ def initialize(datasource, model) super(datasource, name) fetch_fields - # fetch_associations + fetch_associations # enable_count end @@ -65,7 +65,69 @@ def fetch_fields end def fetch_associations - # TODO + @model.relations.transform_values do |association| + case association + when Mongoid::Association::Referenced::HasMany + add_field( + association.name.to_s, + ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema.new( + foreign_collection: format_model_name(association.class_name), + origin_key: association.foreign_key, + origin_key_target: association.primary_key + ) + ) + when Mongoid::Association::Referenced::BelongsTo + if association.polymorphic? + foreign_collections = get_polymorphic_types(association.name) + add_field( + association.name.to_s, + ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicManyToOneSchema.new( + foreign_collections: foreign_collections.keys, + foreign_key: association.foreign_key, + foreign_key_type_field: association.inverse_type, + foreign_key_targets: foreign_collections + ) + ) + schema[:fields][association.foreign_key].is_read_only = true + schema[:fields][association.inverse_type].is_read_only = true + else + add_field( + association.name.to_s, + ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema.new( + foreign_collection: format_model_name(association.class_name), + foreign_key: association.foreign_key, + foreign_key_target: association.primary_key + ) + ) + end + when Mongoid::Association::Referenced::HasOne + add_field( + association.name.to_s, + ForestAdminDatasourceToolkit::Schema::Relations::OneToOneSchema.new( + foreign_collection: format_model_name(association.klass.name), + origin_key: association.foreign_key, + origin_key_target: association.primary_key + ) + ) + when Mongoid::Association::Embedded::EmbedsMany + # 'embeds_many' + when Mongoid::Association::Embedded::EmbedsOne + # 'embeds_one' + when Mongoid::Association::Referenced::HasAndBelongsToMany + datasource.simulate_habtm(model) + + add_field( + association.name.to_s, + ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema.new( + foreign_collection: format_model_name(association.class_name), + origin_key: association.foreign_key, + origin_key_target: association.primary_key + ) + ) + else + 'unknown' + end + end end end end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb index 284830672..3274778ae 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb @@ -10,6 +10,13 @@ def initialize generate end + def simulate_habtm(model) + model.reflect_on_all_associations(:has_and_belongs_to_many).each do |association| + make_through_model(model, association) unless collections.key?(create_through_model_name(model, + association)) + end + end + private def generate @@ -21,5 +28,63 @@ def generate add_collection(Collection.new(self, model)) end end + + def make_through_model(model, association) + # make a fake model to represent the through model + Class.new do + include Mongoid::Document + class << self + attr_accessor :name, :collection_name, :primary_key + end + + def self.add_association(name, options) + belongs_to name, required: false, **options + end + end + + options = {} + + options[:name] = create_through_model_name(model, association) + options[:collection_name] = options[:name].downcase + options[:associations] = [ + { + name: model.name.demodulize.underscore.to_sym, + foreign_collection: model.name, + foreign_key: fetch_foreign_key(model), + foreign_key_target: fetch_foreign_key(model) + }, + { + name: association.klass.name.demodulize.underscore.to_sym, + foreign_collection: association.klass.name, + foreign_key: fetch_foreign_key(association.klass), + foreign_key_target: fetch_foreign_key(association.klass) + } + ] + + # through_model.add_association(model.name.demodulize.underscore.to_sym, + # class_name: model.name, + # foreign_key: fetch_foreign_key(model)) + # through_model.add_association(association.klass.name.demodulize.underscore.to_sym, + # class_name: association.klass.name, + # foreign_key: fetch_foreign_key(association.klass)) + + add_collection(ThroughCollection.new(self, options)) + end + + def create_through_model_name(model, association) + [model, association.klass] + .map { |klass| klass.name.split('::').last } + .sort + .join('_') + end + + def fetch_primary_key(klass) + klass.fields.find { |_, field| field.options[:identity] || field.name == '_id' }&.first + end + + def fetch_foreign_key(klass) + klass.fields.find { |_, field| field.options[:identity] }&.first || '_id' + # fetch_primary_key(klass).to_s + end end end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb index cf9d38739..c9d5ae1f5 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb @@ -41,6 +41,28 @@ module Relation # # types # end + + # def get_polymorphic_types(relation_name) + # ObjectSpace.each_object(Class).select do |klass| + # next unless klass < Mongoid::Document + # klass.relations.any? do |_rel_name, relation| + # relation.options[:as].to_s == relation_name.to_s + # end + # end.map(&:name) + # end + + def get_polymorphic_types(relation_name) + types = {} + + ObjectSpace.each_object(Class).select { |klass| klass < Mongoid::Document }.each do |model| + if model.relations.any? { |_, relation| relation.options[:as] == relation_name.to_sym } + primary_key = model.fields.keys.find { |key| model.fields[key].options[:as] == :id } || :_id + types[model.name] = primary_key + end + end + + types + end end end end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/through_collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/through_collection.rb new file mode 100644 index 000000000..773034af2 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/through_collection.rb @@ -0,0 +1,54 @@ +module ForestAdminDatasourceMongoid + class ThroughCollection < ForestAdminDatasourceToolkit::Collection + include Parser::Column + # include Parser::Relation + include ForestAdminDatasourceToolkit::Components::Query + + attr_reader :model, :options + + def initialize(datasource, options = {}) + # @model = model + # name = format_model_name(@model.name) + @options = options + super(datasource, options[:name]) + add_relations + end + + def add_relations + @options[:associations].each do |association| + # todo + # field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( + # column_type: get_column_type(column), + # filter_operators: operators_for_column_type(get_column_type(column)), + # is_primary_key: column.object_id_field?, + # is_read_only: false, + # is_sortable: true, + # default_value: column.object_id_field? ? nil : get_default_value(column), + # enum_values: get_enum_values(column), + # # validations: get_validations(column) + # validations: [] + # ) + # + # add_field(column_name, field) + + add_field( + association[:name].to_s, + ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema.new( + foreign_collection: association[:foreign_collection], + foreign_key: association[:foreign_key], + foreign_key_target: association[:foreign_key_target] + ) + ) + end + + # add_field( + # association.name.to_s, + # ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema.new( + # foreign_collection: format_model_name(association.class_name), + # foreign_key: association.foreign_key, + # foreign_key_target: association.primary_key + # ) + # ) + end + end +end From 6aa048bc6c69441bc494f4e2ef7553fb0c11e665 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 15 Jan 2025 09:59:17 +0100 Subject: [PATCH 005/104] fix: relation --- .../lib/forest_admin_datasource_mongoid/collection.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index 66c744e74..b384252e1 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -2,7 +2,7 @@ module ForestAdminDatasourceMongoid class Collection < ForestAdminDatasourceToolkit::Collection include Parser::Column include Parser::Validation - # include Parser::Relation + include Parser::Relation include ForestAdminDatasourceToolkit::Components::Query attr_reader :model @@ -71,7 +71,7 @@ def fetch_associations add_field( association.name.to_s, ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema.new( - foreign_collection: format_model_name(association.class_name), + foreign_collection: format_model_name(association.klass.name), origin_key: association.foreign_key, origin_key_target: association.primary_key ) @@ -94,7 +94,7 @@ def fetch_associations add_field( association.name.to_s, ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema.new( - foreign_collection: format_model_name(association.class_name), + foreign_collection: format_model_name(association.klass.name), foreign_key: association.foreign_key, foreign_key_target: association.primary_key ) @@ -119,7 +119,7 @@ def fetch_associations add_field( association.name.to_s, ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema.new( - foreign_collection: format_model_name(association.class_name), + foreign_collection: format_model_name(association.klass.name), origin_key: association.foreign_key, origin_key_target: association.primary_key ) From fcc65f84f8804640a476bd9f5884fba2151d3a88 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 15 Jan 2025 16:31:29 +0100 Subject: [PATCH 006/104] feat(relation): handle has_and_belongs_to_many --- .../collection.rb | 7 +++- .../datasource.rb | 20 +++------- .../through_collection.rb | 38 +++++++------------ 3 files changed, 24 insertions(+), 41 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index b384252e1..fae756b04 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -114,13 +114,16 @@ def fetch_associations when Mongoid::Association::Embedded::EmbedsOne # 'embeds_one' when Mongoid::Association::Referenced::HasAndBelongsToMany - datasource.simulate_habtm(model) + # datasource.simulate_habtm(model) + foreign_key_of_association = association.klass.reflect_on_all_associations.find do |assoc| + assoc.klass == association.inverse_class_name.constantize + end&.foreign_key add_field( association.name.to_s, ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema.new( foreign_collection: format_model_name(association.klass.name), - origin_key: association.foreign_key, + origin_key: foreign_key_of_association, origin_key_target: association.primary_key ) ) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb index 3274778ae..04f6ab46c 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb @@ -12,8 +12,7 @@ def initialize def simulate_habtm(model) model.reflect_on_all_associations(:has_and_belongs_to_many).each do |association| - make_through_model(model, association) unless collections.key?(create_through_model_name(model, - association)) + make_through_model(model, association) unless collections.key?(create_through_model_name(model, association)) end end @@ -43,31 +42,24 @@ def self.add_association(name, options) end options = {} - options[:name] = create_through_model_name(model, association) options[:collection_name] = options[:name].downcase + options[:associations] = [ { name: model.name.demodulize.underscore.to_sym, - foreign_collection: model.name, + foreign_collection: model.name.gsub('::', '__'), foreign_key: fetch_foreign_key(model), - foreign_key_target: fetch_foreign_key(model) + foreign_key_target: fetch_primary_key(association.klass) }, { name: association.klass.name.demodulize.underscore.to_sym, - foreign_collection: association.klass.name, + foreign_collection: association.klass.name.gsub('::', '__'), foreign_key: fetch_foreign_key(association.klass), - foreign_key_target: fetch_foreign_key(association.klass) + foreign_key_target: fetch_primary_key(model) } ] - # through_model.add_association(model.name.demodulize.underscore.to_sym, - # class_name: model.name, - # foreign_key: fetch_foreign_key(model)) - # through_model.add_association(association.klass.name.demodulize.underscore.to_sym, - # class_name: association.klass.name, - # foreign_key: fetch_foreign_key(association.klass)) - add_collection(ThroughCollection.new(self, options)) end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/through_collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/through_collection.rb index 773034af2..0a1f97673 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/through_collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/through_collection.rb @@ -1,7 +1,6 @@ module ForestAdminDatasourceMongoid class ThroughCollection < ForestAdminDatasourceToolkit::Collection include Parser::Column - # include Parser::Relation include ForestAdminDatasourceToolkit::Components::Query attr_reader :model, :options @@ -16,21 +15,6 @@ def initialize(datasource, options = {}) def add_relations @options[:associations].each do |association| - # todo - # field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( - # column_type: get_column_type(column), - # filter_operators: operators_for_column_type(get_column_type(column)), - # is_primary_key: column.object_id_field?, - # is_read_only: false, - # is_sortable: true, - # default_value: column.object_id_field? ? nil : get_default_value(column), - # enum_values: get_enum_values(column), - # # validations: get_validations(column) - # validations: [] - # ) - # - # add_field(column_name, field) - add_field( association[:name].to_s, ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema.new( @@ -39,16 +23,20 @@ def add_relations foreign_key_target: association[:foreign_key_target] ) ) - end - # add_field( - # association.name.to_s, - # ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema.new( - # foreign_collection: format_model_name(association.class_name), - # foreign_key: association.foreign_key, - # foreign_key_target: association.primary_key - # ) - # ) + field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( + column_type: get_column_type(column), + filter_operators: operators_for_column_type(get_column_type(column)), + is_primary_key: column.object_id_field?, + is_read_only: false, + is_sortable: true, + default_value: column.object_id_field? ? nil : get_default_value(column), + enum_values: [], + validations: get_validations(column) + ) + + add_field(column_name, field) + end end end end From b4ac340b0f37a89d1db85ecee0dc17878fa4e7f2 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Fri, 17 Jan 2025 11:33:51 +0100 Subject: [PATCH 007/104] feat(mongo): add query builder --- .rubocop.yml | 2 + .../utils/condition_tree_parser.rb | 3 +- .../collection.rb | 8 +- .../parser/column.rb | 34 ++-- .../utils/query.rb | 151 ++++++++++++++++++ .../query/condition_tree/operators.rb | 1 + .../condition_tree/transforms/pattern.rb | 2 +- 7 files changed, 177 insertions(+), 24 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb diff --git a/.rubocop.yml b/.rubocop.yml index 6715e7c79..481b90795 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -254,6 +254,8 @@ Metrics/MethodLength: - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/form_factory.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb' + Metrics/BlockLength: Exclude: diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/utils/condition_tree_parser.rb b/packages/forest_admin_agent/lib/forest_admin_agent/utils/condition_tree_parser.rb index 7c75e3151..9825bb6df 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/utils/condition_tree_parser.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/utils/condition_tree_parser.rb @@ -31,9 +31,8 @@ def self.from_plain_object(collection, filters) def self.parse_value(collection, leaf) schema = Collection.get_field_schema(collection, leaf[:field]) - operator = leaf[:operator].titleize.tr(' ', '_') - if operator == Operators::IN && leaf[:field].is_a?(String) + if leaf[:operator] == Operators::IN && leaf[:field].is_a?(String) values = leaf[:value].split(',').map(&:strip) return values.map { |item| !%w[false 0 no].include?(item) } if schema.column_type == 'Boolean' diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index fae756b04..9c9361e53 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -17,10 +17,10 @@ def initialize(datasource, model) # enable_count end - def list(_caller, _filter, projection) - # query = Utils::Query.new(self, projection, filter) - - @model.all.map { |record| Utils::MongoidSerializer.new(record).to_hash(projection) } + def list(_caller, filter, projection) + Utils::Query.new(self, projection, filter) + .get + .map { |record| Utils::MongoidSerializer.new(record).to_hash(projection) } end def aggregate(_caller, filter, aggregation, limit = nil) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb index 45300fcb8..8ad16797f 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb @@ -26,13 +26,8 @@ module Column }.freeze def get_column_type(column) - # if model.respond_to?(:defined_enums) && - # model.defined_enums.key?(column.name) - # return 'Enum' - # end + return 'String' if column.foreign_key? - # is_array = column.respond_to?(:array) && column.array == true - # is_array ? "[#{TYPES[column.type]}]" : TYPES[column.type] TYPES[column.type.to_s] || 'String' end @@ -47,21 +42,26 @@ def get_default_value(column) end def operators_for_column_type(type) - result = [Operators::PRESENT, Operators::MISSING] - equality = [Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN] + default_operators = [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL] + in_operators = [Operators::IN, Operators::NOT_IN] + string_operators = [Operators::MATCH, Operators::NOT_CONTAINS, Operators::NOT_I_CONTAINS] + comparison_operators = [Operators::GREATER_THAN, Operators::LESS_THAN] + result = [] if type.is_a? String - orderables = [Operators::LESS_THAN, Operators::GREATER_THAN] - strings = [Operators::LIKE, Operators::I_LIKE, Operators::NOT_CONTAINS] - - result += equality if %w[Boolean Binary Enum Uuid].include?(type) - - result = result + equality + orderables if %w[Date Dateonly Number].include?(type) - - result = result + equality + orderables + strings if %w[String].include?(type) + case type + when 'Boolean', 'Binary', 'Json' + result = default_operators + when 'Date', 'Dateonly', 'Number' + result = default_operators + in_operators + comparison_operators + when 'Enum' + result = default_operators + in_operators + when 'String' + result = default_operators + in_operators + string_operators + end end - result = result + equality + ['Includes_All'] if type.is_a? Array + result = default_operators if type.is_a? Array result end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb new file mode 100644 index 000000000..a8a4eed36 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb @@ -0,0 +1,151 @@ +module ForestAdminDatasourceMongoid + module Utils + class Query + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + + attr_reader :query + + def initialize(collection, projection, filter) + @collection = collection + @query = @collection.model.unscoped + @projection = projection + @filter = filter + @select = [] + end + + def build + build_select + apply_filter + apply_select + end + + def get + build + + return @query.all if @filter.page.nil? + + @query.skip(@filter.page.offset).limit(@filter.page.limit) + end + + def apply_filter + @query = apply_condition_tree(@filter.condition_tree) unless @filter.condition_tree.nil? + @query = apply_sort(@filter.sort) unless @filter.sort.nil? + + @query + end + + def apply_sort(sort) + sort.each do |sort_clause| + field = format_field(sort_clause[:field]) + @query = @query.order(field => sort_clause[:ascending] ? :asc : :desc) + end + + @query + end + + def apply_pagination(page) + @query.offset(page.offset).limit(page.limit) + + @query + end + + def apply_condition_tree(condition_tree, aggregator = nil) + if condition_tree.is_a? Nodes::ConditionTreeBranch + aggregator = condition_tree.aggregator.downcase.to_sym + condition_tree.conditions.each do |condition| + @query = apply_condition_tree(condition, aggregator) + @query = @query.send(aggregator, @query) + end + + @query + else + @query = compute_main_operator(condition_tree, aggregator || :and) + end + end + + def compute_main_operator(condition_tree, aggregator) + field = format_field(condition_tree.field) + value = condition_tree.value + aggregator = aggregator.to_sym + + case condition_tree.operator + when Operators::PRESENT + @query = @query.send(aggregator, @collection.model.not.where(field => nil)) + when Operators::EQUAL + @query = @query.send(aggregator, @collection.model.where(field => value)) + when Operators::IN + @query = @query.send(aggregator, @collection.model.in(field => value)) + when Operators::NOT_EQUAL + @query = @query.send(aggregator, @collection.model.not.where(field => value)) + when Operators::NOT_IN + @query = @query.send(aggregator, @collection.model.not.in(field => value)) + when Operators::GREATER_THAN + @query = @query.send(aggregator, @collection.model.where(field => { '$gt' => value })) + when Operators::LESS_THAN + @query = @query.send(aggregator, @collection.model.where(field => { '$lt' => value })) + when Operators::NOT_CONTAINS + @query = @query.send(aggregator, @collection.model.not.where(field => Regexp.new("^.*#{value}.*$"))) + when Operators::NOT_I_CONTAINS + @query = @query.send(aggregator, @collection.model.not.where(field => Regexp.new("^.*#{value}.*$", 'i'))) + when Operators::MATCH + @query = @query.send(aggregator, @collection.model.where(field => { '$regex' => value })) + when Operators::INCLUDES_ALL + @query = @query.send(aggregator, @collection.model.where(field => { '$all' => value })) + end + + @query + end + + def build_select + unless @projection.nil? + @select = @projection.columns + @projection.relations.each_key do |relation| + relation_schema = @collection.schema[:fields][relation] + @select << if %w[OneToOne PolymorphicOneToOne].include?(relation_schema.type) + "#{@collection.model.collection_name}.#{relation_schema.origin_key_target}" + else + "#{@collection.model.collection_name}.#{relation_schema.foreign_key}" + end + end + end + @query + end + + def apply_select + @query = @query.only(@select) if @select + @query = @query.includes(format_relation_projection(@projection)) unless @projection.nil? + + @query + end + + def add_join_relation(relation_name) + @query = @query.includes(relation_name.to_sym) + + @query + end + + def format_field(field) + if field.include?(':') + relation_name, field = field.split(':') + relation = @collection.schema[:fields][relation_name] + table_name = @collection.datasource.get_collection(relation.foreign_collection).model.table_name + add_join_relation(relation_name) + + return "#{table_name}.#{field}" + end + + field + end + + def format_relation_projection(projection) + result = {} + projection&.relations&.each do |relation, projection_relation| + formatted_relations = format_relation_projection(projection_relation) + + result[relation.to_sym] = formatted_relations + end + result + end + end + end +end diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/operators.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/operators.rb index c8fc379f3..ebf16330f 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/operators.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/operators.rb @@ -13,6 +13,7 @@ class Operators NOT_CONTAINS = 'not_contains'.freeze CONTAINS = 'contains'.freeze I_CONTAINS = 'i_contains'.freeze + NOT_I_CONTAINS = 'not_i_contains'.freeze LONGER_THAN = 'longer_than'.freeze SHORTER_THAN = 'shorter_than'.freeze INCLUDES_ALL = 'includes_all'.freeze diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/pattern.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/pattern.rb index f389eb1fc..1adad2d30 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/pattern.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/pattern.rb @@ -23,7 +23,7 @@ def self.match(case_sensitive) regex.gsub!('%', '.*') regex.tr!('_', '.') - leaf.override(operator: Operators::MATCH, value: "/^#{regex}$/#{case_sensitive ? "" : "i"}") + leaf.override(operator: Operators::MATCH, value: Regexp.new("^#{regex}$", case_sensitive ? '' : 'i')) } } end From 4a5801614c7d6f4c737b45f48674d97d16aa2fe0 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Jan 2025 16:49:46 +0100 Subject: [PATCH 008/104] feat(relation): handle embedded relations --- .../collection.rb | 67 +++++++++++++++++-- .../datasource.rb | 6 +- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index 9c9361e53..f3f3db0d3 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -5,6 +5,8 @@ class Collection < ForestAdminDatasourceToolkit::Collection include Parser::Relation include ForestAdminDatasourceToolkit::Components::Query + MAX_DEPTH = 1 + attr_reader :model def initialize(datasource, model) @@ -12,7 +14,7 @@ def initialize(datasource, model) name = format_model_name(@model.name) super(datasource, name) - fetch_fields + fetch_fields(@model) fetch_associations # enable_count end @@ -47,8 +49,8 @@ def format_model_name(class_name) class_name.gsub('::', '__') end - def fetch_fields - @model.fields.each do |column_name, column| + def fetch_fields(model, prefix = nil) + model.fields.each do |column_name, column| field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( column_type: get_column_type(column), filter_operators: operators_for_column_type(get_column_type(column)), @@ -60,7 +62,11 @@ def fetch_fields validations: get_validations(column) ) - add_field(column_name, field) + if prefix + add_field("#{prefix}.#{column_name}", field) + else + add_field(column_name, field) + end end end @@ -110,9 +116,11 @@ def fetch_associations ) ) when Mongoid::Association::Embedded::EmbedsMany - # 'embeds_many' + # simplify this type of relationship by considering them to be sub-fields and encapsulating it in an array + add_embedded_fields(association, "#{association.name}.[]") when Mongoid::Association::Embedded::EmbedsOne - # 'embeds_one' + # simplify this type of relationship by considering them to be sub-fields + add_embedded_fields(association, association.name) when Mongoid::Association::Referenced::HasAndBelongsToMany # datasource.simulate_habtm(model) foreign_key_of_association = association.klass.reflect_on_all_associations.find do |assoc| @@ -132,5 +140,52 @@ def fetch_associations end end end + + # def add_embedded_fields(association, prefix = '', depth = 0) + # fetch_fields(association.klass, prefix) + # association.klass.relations.each do |sub_name, sub_association| + # field_name = "#{prefix}.#{sub_name}" + # + # if depth >= MAX_DEPTH + # make_field(field_name, Hash) + # else + # case sub_association + # when Mongoid::Association::Embedded::EmbedsOne + # make_field(field_name, Hash) + # add_embedded_fields(sub_association, field_name, depth + 1) + # debugger + # when Mongoid::Association::Embedded::EmbedsMany + # make_field(field_name, Array) + # add_embedded_fields(sub_association, "#{field_name}[]", depth + 1) + # else + # make_field(field_name, sub_association.class) + # end + # end + # end + # end + def add_embedded_fields(association, prefix = '') + fetch_fields(association.klass, prefix) + + association.klass.relations.each do |sub_name, sub_association| + field_name = "#{prefix}.#{sub_name}" + + next if sub_association.is_a?(Mongoid::Association::Embedded::EmbeddedIn) + + if sub_association.is_a?(Mongoid::Association::Embedded::EmbedsOne) || + sub_association.is_a?(Mongoid::Association::Embedded::EmbedsMany) + make_field(field_name, Hash) + else + make_field(field_name, sub_association.class) + end + end + end + + def make_field(name, type) + schema[:fields][name] = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( + column_type: type.to_s, + is_primary_key: false, + is_sortable: true + ) + end end end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb index 04f6ab46c..216e0a183 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb @@ -20,7 +20,7 @@ def simulate_habtm(model) def generate models = ObjectSpace.each_object(Class).select do |klass| - klass < Mongoid::Document && !klass.name.start_with?('Mongoid::') + klass < Mongoid::Document && !klass.name.start_with?('Mongoid::') && !embedded_in_relation?(klass) end models.each do |model| @@ -28,6 +28,10 @@ def generate end end + def embedded_in_relation?(klass) + klass.relations.any? { |_name, association| association.is_a?(Mongoid::Association::Embedded::EmbeddedIn) } + end + def make_through_model(model, association) # make a fake model to represent the through model Class.new do From 283093000c3928814b33e4cd50bef5e62e34d1f1 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Mon, 20 Jan 2025 14:11:29 +0100 Subject: [PATCH 009/104] feat(mongo): add query aggregate builder --- .../collection.rb | 4 +- .../utils/query.rb | 1 + .../utils/query_aggregate.rb | 60 +++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query_aggregate.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index f3f3db0d3..9ac0282b7 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -16,7 +16,7 @@ def initialize(datasource, model) fetch_fields(@model) fetch_associations - # enable_count + enable_count end def list(_caller, filter, projection) @@ -26,7 +26,7 @@ def list(_caller, filter, projection) end def aggregate(_caller, filter, aggregation, limit = nil) - # Utils::QueryAggregate.new(self, aggregation, filter, limit).get + Utils::QueryAggregate.new(self, aggregation, filter, limit).get end def create(_caller, data) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb index a8a4eed36..a02f61d15 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb @@ -125,6 +125,7 @@ def add_join_relation(relation_name) end def format_field(field) + # TODO if field.include?(':') relation_name, field = field.split(':') relation = @collection.schema[:fields][relation_name] diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query_aggregate.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query_aggregate.rb new file mode 100644 index 000000000..1ce588b3d --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query_aggregate.rb @@ -0,0 +1,60 @@ +module ForestAdminDatasourceMongoid + module Utils + class QueryAggregate < Query + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + include ForestAdminDatasourceToolkit::Components::Query + + GROUP_OPERATION = { + 'Year' => '%Y-01-01', + 'Month' => '%Y-%m-01', + 'Day' => '%Y-%m-%d', + 'Week' => '%Y-%m-%d' + }.freeze + + def initialize(collection, aggregation, filter = nil, limit = nil) + super(collection, ForestAdminDatasourceToolkit::Components::Query::Projection.new, filter) + @aggregation = aggregation + @limit = limit + @operation = aggregation.operation.downcase + @field = aggregation.field.nil? ? {} : format_field(aggregation.field) + end + + def get + build + @query = @query.limit(@limit) if @limit + @query = @query.group(_id: compute_groups, value: { "$#{@operation}" => @field }) + .project(_id: 0, value: '$value', group: compute_groups_projection) + + @query.collection.aggregate(@query.pipeline).to_a + end + + private + + def compute_groups + @aggregation.groups.reduce({}) do |memo, group| + field = "$#{format_field(group[:field])}" + + if group.key?(:operation) + if group[:operation] == 'Week' + date = { '$dateTrunc' => { 'date' => field, 'startOfWeek' => 'Monday', 'unit' => 'week' } } + field = { '$dateToString' => { 'format' => GROUP_OPERATION[group[:operation]], 'date' => date } } + else + field = { '$dateToString' => { 'format' => GROUP_OPERATION[group[:operation]], 'date' => field } } + end + end + + memo.merge({ group[:field] => field }) + end + end + + def compute_groups_projection + if @aggregation.groups.length.positive? + return @aggregation.groups + .reduce({}) { |memo, group| memo.merge({ group[:field] => "$_id.#{group[:field]}" }) } + end + + { '$literal' => {} } + end + end + end +end From f317e5cc12dbaab127d573d887fe53853e97cb22 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 20 Jan 2025 17:09:42 +0100 Subject: [PATCH 010/104] fix(relations): polymorphic & embedded --- .../collection.rb | 56 ++++++++++++++----- .../parser/relation.rb | 52 +---------------- .../utils/query.rb | 16 ++++-- 3 files changed, 55 insertions(+), 69 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index 9ac0282b7..461ee199a 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -74,14 +74,27 @@ def fetch_associations @model.relations.transform_values do |association| case association when Mongoid::Association::Referenced::HasMany - add_field( - association.name.to_s, - ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema.new( - foreign_collection: format_model_name(association.klass.name), - origin_key: association.foreign_key, - origin_key_target: association.primary_key + if association.polymorphic? + add_field( + association.name.to_s, + ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicOneToManySchema.new( + foreign_collection: format_model_name(association.klass.name), + origin_key: association.foreign_key, + origin_key_target: association.primary_key, + origin_type_field: association.type, + origin_type_value: association.inverse_class_name.constantize + ) ) - ) + else + add_field( + association.name.to_s, + ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema.new( + foreign_collection: format_model_name(association.klass.name), + origin_key: association.foreign_key, + origin_key_target: association.primary_key + ) + ) + end when Mongoid::Association::Referenced::BelongsTo if association.polymorphic? foreign_collections = get_polymorphic_types(association.name) @@ -107,14 +120,27 @@ def fetch_associations ) end when Mongoid::Association::Referenced::HasOne - add_field( - association.name.to_s, - ForestAdminDatasourceToolkit::Schema::Relations::OneToOneSchema.new( - foreign_collection: format_model_name(association.klass.name), - origin_key: association.foreign_key, - origin_key_target: association.primary_key + if association.polymorphic? + add_field( + association.name.to_s, + ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicOneToOneSchema.new( + foreign_collection: format_model_name(association.klass.name), + origin_key: association.foreign_key, + origin_key_target: association.primary_key, + origin_type_field: association.type, + origin_type_value: association.inverse_class_name.constantize + ) ) - ) + else + add_field( + association.name.to_s, + ForestAdminDatasourceToolkit::Schema::Relations::OneToOneSchema.new( + foreign_collection: format_model_name(association.klass.name), + origin_key: association.foreign_key, + origin_key_target: association.primary_key + ) + ) + end when Mongoid::Association::Embedded::EmbedsMany # simplify this type of relationship by considering them to be sub-fields and encapsulating it in an array add_embedded_fields(association, "#{association.name}.[]") @@ -164,6 +190,8 @@ def fetch_associations # end # end def add_embedded_fields(association, prefix = '') + return make_field(association.name.to_s, Hash) if association.is_a?(Mongoid::Association::Embedded::EmbedsMany) + fetch_fields(association.klass, prefix) association.klass.relations.each do |sub_name, sub_association| diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb index c9d5ae1f5..5dc752737 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb @@ -1,63 +1,13 @@ module ForestAdminDatasourceMongoid module Parser module Relation - # TODO - - # def associations(model, support_polymorphic_relations: false) - # model.reflect_on_all_associations.select do |association| - # is_valid_association = !get_class(association).nil? && !active_type?(get_class(association)) - # if support_polymorphic_relations - # polymorphic?(association) ? true : is_valid_association - # else - # !polymorphic?(association) && is_valid_association - # end - # end - # end - # - # def polymorphic?(association) - # (association.options.key?(:polymorphic) && association.options[:polymorphic]) || - # association.inverse_of&.polymorphic? - # end - # - # def get_class(association) - # association.klass - # rescue StandardError - # nil - # end - # - # # NOTICE: Ignores ActiveType::Object association during introspection and interactions. - # # See the gem documentation: https://github.com/makandra/active_type - # def active_type?(model) - # Object.const_defined?('ActiveType::Object') && model < ActiveType::Object - # end - # - # def get_polymorphic_types(relation) - # types = {} - # @datasource.models.each do |model| - # unless model.reflect_on_all_associations.none? { |assoc| assoc.options[:as] == relation.name.to_sym } - # types[format_model_name(model.name)] = model.primary_key - # end - # end - # - # types - # end - - # def get_polymorphic_types(relation_name) - # ObjectSpace.each_object(Class).select do |klass| - # next unless klass < Mongoid::Document - # klass.relations.any? do |_rel_name, relation| - # relation.options[:as].to_s == relation_name.to_s - # end - # end.map(&:name) - # end - def get_polymorphic_types(relation_name) types = {} ObjectSpace.each_object(Class).select { |klass| klass < Mongoid::Document }.each do |model| if model.relations.any? { |_, relation| relation.options[:as] == relation_name.to_sym } primary_key = model.fields.keys.find { |key| model.fields[key].options[:as] == :id } || :_id - types[model.name] = primary_key + types[model.name] = primary_key.to_s end end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb index a02f61d15..d75c0d8ad 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb @@ -139,12 +139,20 @@ def format_field(field) end def format_relation_projection(projection) - result = {} - projection&.relations&.each do |relation, projection_relation| + # result = {} + # projection&.relations&.each do |relation, projection_relation| + # formatted_relations = format_relation_projection(projection_relation) + # + # result[relation.to_sym] = formatted_relations + # end + # result + + result = [] + projection&.relations&.each_value do |projection_relation| formatted_relations = format_relation_projection(projection_relation) - - result[relation.to_sym] = formatted_relations + result += formatted_relations end + result end end From 7ec49ac5df332aced195017e75781d83ff45e852 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 20 Jan 2025 17:50:22 +0100 Subject: [PATCH 011/104] chore: add dummy rails app & setup spec config --- .../forest_admin_datasource_mongoid/Gemfile | 3 +- .../Gemfile-test | 2 + .../lib/forest_admin_datasource_mongoid.rb | 1 + .../spec/dummy/.github/dependabot.yml | 12 ++ .../spec/dummy/.github/workflows/ci.yml | 39 ++++++ .../dummy/.kamal/hooks/docker-setup.sample | 3 + .../dummy/.kamal/hooks/post-deploy.sample | 14 +++ .../.kamal/hooks/post-proxy-reboot.sample | 3 + .../spec/dummy/.kamal/hooks/pre-build.sample | 51 ++++++++ .../dummy/.kamal/hooks/pre-connect.sample | 47 ++++++++ .../spec/dummy/.kamal/hooks/pre-deploy.sample | 109 +++++++++++++++++ .../.kamal/hooks/pre-proxy-reboot.sample | 3 + .../spec/dummy/.kamal/secrets | 17 +++ .../spec/dummy/Rakefile | 6 + .../spec/dummy/app/assets/images/.keep | 0 .../app/assets/stylesheets/application.css | 10 ++ .../app/controllers/application_controller.rb | 4 + .../spec/dummy/app/controllers/concerns/.keep | 0 .../dummy/app/helpers/application_helper.rb | 2 + .../spec/dummy/app/jobs/application_job.rb | 7 ++ .../dummy/app/mailers/application_mailer.rb | 4 + .../spec/dummy/app/models/address.rb | 8 ++ .../spec/dummy/app/models/author.rb | 8 ++ .../spec/dummy/app/models/band.rb | 5 + .../spec/dummy/app/models/comment.rb | 8 ++ .../spec/dummy/app/models/concerns/.keep | 0 .../spec/dummy/app/models/departure.rb | 7 ++ .../spec/dummy/app/models/label.rb | 6 + .../spec/dummy/app/models/post.rb | 10 ++ .../spec/dummy/app/models/section.rb | 6 + .../spec/dummy/app/models/tag.rb | 7 ++ .../spec/dummy/app/models/team.rb | 7 ++ .../spec/dummy/app/models/user.rb | 8 ++ .../app/views/layouts/application.html.erb | 27 +++++ .../dummy/app/views/layouts/mailer.html.erb | 13 ++ .../dummy/app/views/layouts/mailer.text.erb | 1 + .../dummy/app/views/pwa/manifest.json.erb | 22 ++++ .../dummy/app/views/pwa/service-worker.js | 26 ++++ .../spec/dummy/bin/brakeman | 7 ++ .../spec/dummy/bin/bundle | 109 +++++++++++++++++ .../spec/dummy/bin/dev | 2 + .../spec/dummy/bin/docker-entrypoint | 9 ++ .../spec/dummy/bin/kamal | 27 +++++ .../spec/dummy/bin/rails | 4 + .../spec/dummy/bin/rake | 4 + .../spec/dummy/bin/rubocop | 8 ++ .../spec/dummy/bin/setup | 26 ++++ .../spec/dummy/bin/thrust | 5 + .../spec/dummy/config.ru | 6 + .../spec/dummy/config/application.rb | 44 +++++++ .../spec/dummy/config/boot.rb | 11 ++ .../spec/dummy/config/credentials.yml.enc | 1 + .../spec/dummy/config/deploy.yml | 102 ++++++++++++++++ .../spec/dummy/config/environment.rb | 5 + .../dummy/config/environments/development.rb | 57 +++++++++ .../dummy/config/environments/production.rb | 80 ++++++++++++ .../spec/dummy/config/environments/test.rb | 50 ++++++++ .../spec/dummy/config/initializers/assets.rb | 7 ++ .../initializers/content_security_policy.rb | 25 ++++ .../initializers/filter_parameter_logging.rb | 8 ++ .../dummy/config/initializers/inflections.rb | 16 +++ .../spec/dummy/config/initializers/mongoid.rb | 13 ++ .../spec/dummy/config/locales/en.yml | 31 +++++ .../spec/dummy/config/master.key | 1 + .../spec/dummy/config/mongoid.yml | 8 ++ .../spec/dummy/config/puma.rb | 38 ++++++ .../spec/dummy/config/routes.rb | 14 +++ .../spec/dummy/lib/tasks/.keep | 0 .../spec/dummy/public/400.html | 114 ++++++++++++++++++ .../spec/dummy/public/404.html | 114 ++++++++++++++++++ .../dummy/public/406-unsupported-browser.html | 114 ++++++++++++++++++ .../spec/dummy/public/422.html | 114 ++++++++++++++++++ .../spec/dummy/public/500.html | 114 ++++++++++++++++++ .../spec/dummy/public/icon.png | Bin 0 -> 4166 bytes .../spec/dummy/public/icon.svg | 3 + .../spec/dummy/public/robots.txt | 1 + .../spec/dummy/script/.keep | 0 .../spec/spec_helper.rb | 32 ++--- 78 files changed, 1806 insertions(+), 24 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/.github/dependabot.yml create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/.github/workflows/ci.yml create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/docker-setup.sample create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/post-deploy.sample create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/post-proxy-reboot.sample create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-build.sample create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-connect.sample create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-deploy.sample create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-proxy-reboot.sample create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/secrets create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/Rakefile create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/assets/images/.keep create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/assets/stylesheets/application.css create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/controllers/application_controller.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/controllers/concerns/.keep create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/helpers/application_helper.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/jobs/application_job.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/mailers/application_mailer.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/models/address.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/models/author.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/models/band.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/models/comment.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/models/concerns/.keep create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/models/departure.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/models/label.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/models/post.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/models/section.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/models/tag.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/models/team.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/models/user.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/views/layouts/application.html.erb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/views/layouts/mailer.html.erb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/views/layouts/mailer.text.erb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/views/pwa/manifest.json.erb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/views/pwa/service-worker.js create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/bin/brakeman create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/bin/bundle create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/bin/dev create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/bin/docker-entrypoint create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/bin/kamal create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/bin/rails create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/bin/rake create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/bin/rubocop create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/bin/setup create mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/bin/thrust create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config.ru create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/application.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/boot.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/credentials.yml.enc create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/deploy.yml create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/environment.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/environments/development.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/environments/production.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/environments/test.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/assets.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/content_security_policy.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/filter_parameter_logging.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/inflections.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/mongoid.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/locales/en.yml create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/master.key create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/mongoid.yml create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/puma.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/config/routes.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/lib/tasks/.keep create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/public/400.html create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/public/404.html create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/public/406-unsupported-browser.html create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/public/422.html create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/public/500.html create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/public/icon.png create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/public/icon.svg create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/public/robots.txt create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/script/.keep diff --git a/packages/forest_admin_datasource_mongoid/Gemfile b/packages/forest_admin_datasource_mongoid/Gemfile index 9c25bd32d..dc73c56bd 100644 --- a/packages/forest_admin_datasource_mongoid/Gemfile +++ b/packages/forest_admin_datasource_mongoid/Gemfile @@ -3,13 +3,14 @@ source "https://rubygems.org" # Specify your gem's dependencies in forest_admin_datasource_mongoid.gemspec gemspec -gem 'mongoid' +gem 'mongoid', '~> 9.0' gem 'forest_admin_datasource_toolkit' gem 'rake', '~> 13.0' gem 'rubocop', '~> 1.21' group :development, :test do + gem 'mongoid-rspec', '~> 4.0' gem 'rails' gem 'rspec-rails', '~> 3.0' gem 'simplecov', '~> 0.22', require: false diff --git a/packages/forest_admin_datasource_mongoid/Gemfile-test b/packages/forest_admin_datasource_mongoid/Gemfile-test index 88cb7966a..8de2d4f8a 100644 --- a/packages/forest_admin_datasource_mongoid/Gemfile-test +++ b/packages/forest_admin_datasource_mongoid/Gemfile-test @@ -5,9 +5,11 @@ gemspec gem 'rake', '~> 13.0' gem 'rubocop', '~> 1.21' +gem 'mongoid', '~> 9.0' group :development, :test do gem 'forest_admin_datasource_toolkit', path: '../forest_admin_datasource_toolkit' + gem 'mongoid-rspec', '~> 4.0' gem 'rails' gem 'rspec-rails', '~> 3.0' gem 'simplecov', '~> 0.22', require: false diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid.rb index fde794b0c..87d5dd901 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid.rb @@ -3,6 +3,7 @@ require 'zeitwerk' loader = Zeitwerk::Loader.for_gem +loader.ignore("#{__dir__}/models") loader.setup module ForestAdminDatasourceMongoid diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.github/dependabot.yml b/packages/forest_admin_datasource_mongoid/spec/dummy/.github/dependabot.yml new file mode 100644 index 000000000..f0527e6be --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.github/workflows/ci.yml b/packages/forest_admin_datasource_mongoid/spec/dummy/.github/workflows/ci.yml new file mode 100644 index 000000000..fb0f4ea1e --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + scan_ruby: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Scan for common Rails security vulnerabilities using static analysis + run: bin/brakeman --no-pager + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Lint code for consistent style + run: bin/rubocop -f github + diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/docker-setup.sample b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/docker-setup.sample new file mode 100755 index 000000000..2fb07d7d7 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/docker-setup.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Docker set up on $KAMAL_HOSTS..." diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/post-deploy.sample b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/post-deploy.sample new file mode 100755 index 000000000..75efafc10 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/post-deploy.sample @@ -0,0 +1,14 @@ +#!/bin/sh + +# A sample post-deploy hook +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/post-proxy-reboot.sample b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/post-proxy-reboot.sample new file mode 100755 index 000000000..1435a677f --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/post-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooted kamal-proxy on $KAMAL_HOSTS" diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-build.sample b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-build.sample new file mode 100755 index 000000000..f87d81130 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-build.sample @@ -0,0 +1,51 @@ +#!/bin/sh + +# A sample pre-build hook +# +# Checks: +# 1. We have a clean checkout +# 2. A remote is configured +# 3. The branch has been pushed to the remote +# 4. The version we are deploying matches the remote +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) + +if [ -n "$(git status --porcelain)" ]; then + echo "Git checkout is not clean, aborting..." >&2 + git status --porcelain >&2 + exit 1 +fi + +first_remote=$(git remote) + +if [ -z "$first_remote" ]; then + echo "No git remote set, aborting..." >&2 + exit 1 +fi + +current_branch=$(git branch --show-current) + +if [ -z "$current_branch" ]; then + echo "Not on a git branch, aborting..." >&2 + exit 1 +fi + +remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) + +if [ -z "$remote_head" ]; then + echo "Branch not pushed to remote, aborting..." >&2 + exit 1 +fi + +if [ "$KAMAL_VERSION" != "$remote_head" ]; then + echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 + exit 1 +fi + +exit 0 diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-connect.sample b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-connect.sample new file mode 100755 index 000000000..18e61d7e5 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-connect.sample @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby + +# A sample pre-connect check +# +# Warms DNS before connecting to hosts in parallel +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +hosts = ENV["KAMAL_HOSTS"].split(",") +results = nil +max = 3 + +elapsed = Benchmark.realtime do + results = hosts.map do |host| + Thread.new do + tries = 1 + + begin + Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) + rescue SocketError + if tries < max + puts "Retrying DNS warmup: #{host}" + tries += 1 + sleep rand + retry + else + puts "DNS warmup failed: #{host}" + host + end + end + + tries + end + end.map(&:value) +end + +retries = results.sum - hosts.size +nopes = results.count { |r| r == max } + +puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-deploy.sample b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-deploy.sample new file mode 100755 index 000000000..1b280c719 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-deploy.sample @@ -0,0 +1,109 @@ +#!/usr/bin/env ruby + +# A sample pre-deploy hook +# +# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. +# +# Fails unless the combined status is "success" +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_COMMAND +# KAMAL_SUBCOMMAND +# KAMAL_ROLE (if set) +# KAMAL_DESTINATION (if set) + +# Only check the build status for production deployments +if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" + exit 0 +end + +require "bundler/inline" + +# true = install gems so this is fast on repeat invocations +gemfile(true, quiet: true) do + source "https://rubygems.org" + + gem "octokit" + gem "faraday-retry" +end + +MAX_ATTEMPTS = 72 +ATTEMPTS_GAP = 10 + +def exit_with_error(message) + $stderr.puts message + exit 1 +end + +class GithubStatusChecks + attr_reader :remote_url, :git_sha, :github_client, :combined_status + + def initialize + @remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/") + @git_sha = `git rev-parse HEAD`.strip + @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) + refresh! + end + + def refresh! + @combined_status = github_client.combined_status(remote_url, git_sha) + end + + def state + combined_status[:state] + end + + def first_status_url + first_status = combined_status[:statuses].find { |status| status[:state] == state } + first_status && first_status[:target_url] + end + + def complete_count + combined_status[:statuses].count { |status| status[:state] != "pending"} + end + + def total_count + combined_status[:statuses].count + end + + def current_status + if total_count > 0 + "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." + else + "Build not started..." + end + end +end + + +$stdout.sync = true + +puts "Checking build status..." +attempts = 0 +checks = GithubStatusChecks.new + +begin + loop do + case checks.state + when "success" + puts "Checks passed, see #{checks.first_status_url}" + exit 0 + when "failure" + exit_with_error "Checks failed, see #{checks.first_status_url}" + when "pending" + attempts += 1 + end + + exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS + + puts checks.current_status + sleep(ATTEMPTS_GAP) + checks.refresh! + end +rescue Octokit::NotFound + exit_with_error "Build status could not be found" +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-proxy-reboot.sample b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-proxy-reboot.sample new file mode 100755 index 000000000..061f8059e --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/secrets b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/secrets new file mode 100644 index 000000000..9a771a398 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/secrets @@ -0,0 +1,17 @@ +# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, +# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either +# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. + +# Example of extracting secrets from 1password (or another compatible pw manager) +# SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) +# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS}) +# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS}) + +# Use a GITHUB_TOKEN if private repositories are needed for the image +# GITHUB_TOKEN=$(gh config get -h github.com oauth_token) + +# Grab the registry password from ENV +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Improve security by using a password manager. Never check config/master.key into git! +RAILS_MASTER_KEY=$(cat config/master.key) diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/Rakefile b/packages/forest_admin_datasource_mongoid/spec/dummy/Rakefile new file mode 100644 index 000000000..9a5ea7383 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/assets/images/.keep b/packages/forest_admin_datasource_mongoid/spec/dummy/app/assets/images/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/assets/stylesheets/application.css b/packages/forest_admin_datasource_mongoid/spec/dummy/app/assets/stylesheets/application.css new file mode 100644 index 000000000..fe93333c0 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/assets/stylesheets/application.css @@ -0,0 +1,10 @@ +/* + * This is a manifest file that'll be compiled into application.css. + * + * With Propshaft, assets are served efficiently without preprocessing steps. You can still include + * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard + * cascading order, meaning styles declared later in the document or manifest will override earlier ones, + * depending on specificity. + * + * Consider organizing styles into separate files for maintainability. + */ diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/controllers/application_controller.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/controllers/application_controller.rb new file mode 100644 index 000000000..0d95db22b --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/controllers/application_controller.rb @@ -0,0 +1,4 @@ +class ApplicationController < ActionController::Base + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + allow_browser versions: :modern +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/controllers/concerns/.keep b/packages/forest_admin_datasource_mongoid/spec/dummy/app/controllers/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/helpers/application_helper.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/helpers/application_helper.rb new file mode 100644 index 000000000..de6be7945 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/helpers/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/jobs/application_job.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/jobs/application_job.rb new file mode 100644 index 000000000..d394c3d10 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/mailers/application_mailer.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/mailers/application_mailer.rb new file mode 100644 index 000000000..3c34c8148 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/address.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/address.rb new file mode 100644 index 000000000..f8976409a --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/address.rb @@ -0,0 +1,8 @@ +class Address + include Mongoid::Document + field :street, type: String + field :city, type: String + field :zip_code, type: String + + embedded_in :user +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/author.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/author.rb new file mode 100644 index 000000000..ce60b3be4 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/author.rb @@ -0,0 +1,8 @@ +class Author + include Mongoid::Document + include Mongoid::Timestamps + field :first_name, type: String + field :last_name, type: String + + belongs_to :post +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/band.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/band.rb new file mode 100644 index 000000000..70f68cb8b --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/band.rb @@ -0,0 +1,5 @@ +class Band + include Mongoid::Document + include Mongoid::Timestamps + embeds_one :label +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/comment.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/comment.rb new file mode 100644 index 000000000..3af00d4ef --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/comment.rb @@ -0,0 +1,8 @@ +class Comment + include Mongoid::Document + include Mongoid::Timestamps + field :name, type: String + field :message, type: String + + belongs_to :post +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/concerns/.keep b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/concerns/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/departure.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/departure.rb new file mode 100644 index 000000000..9875490b5 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/departure.rb @@ -0,0 +1,7 @@ +class Departure + include Mongoid::Document + include Mongoid::Timestamps + field :label, type: String + + has_one :user, as: :item +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/label.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/label.rb new file mode 100644 index 000000000..d44e69c3f --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/label.rb @@ -0,0 +1,6 @@ +class Label + include Mongoid::Document + field :name, type: String + embedded_in :band + embeds_one :section +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/post.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/post.rb new file mode 100644 index 000000000..74894da72 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/post.rb @@ -0,0 +1,10 @@ +class Post + include Mongoid::Document + include Mongoid::Timestamps + field :title, type: String + field :body, type: String + + has_many :comments, dependent: :destroy + has_one :author + has_and_belongs_to_many :tags +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/section.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/section.rb new file mode 100644 index 000000000..e40fa3552 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/section.rb @@ -0,0 +1,6 @@ +class Section + include Mongoid::Document + field :content, type: String + field :body, type: String + embedded_in :label +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/tag.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/tag.rb new file mode 100644 index 000000000..7c754b092 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/tag.rb @@ -0,0 +1,7 @@ +class Tag + include Mongoid::Document + include Mongoid::Timestamps + field :label, type: String + + has_and_belongs_to_many :posts +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/team.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/team.rb new file mode 100644 index 000000000..c8dc4a52b --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/team.rb @@ -0,0 +1,7 @@ +class Team + include Mongoid::Document + include Mongoid::Timestamps + field :label, type: String + + has_one :user, as: :item +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/user.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/user.rb new file mode 100644 index 000000000..0e2063525 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/user.rb @@ -0,0 +1,8 @@ +class User + include Mongoid::Document + include Mongoid::Timestamps + + field :name, type: String + belongs_to :item, polymorphic: true + embeds_many :addresses +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/views/layouts/application.html.erb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/views/layouts/application.html.erb new file mode 100644 index 000000000..f25ae92d4 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/views/layouts/application.html.erb @@ -0,0 +1,27 @@ + + + + <%= content_for(:title) || "Dummy" %> + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> + <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> + + + + + + <%# Includes all stylesheet files in app/assets/stylesheets %> + <%= stylesheet_link_tag :app %> + + + + <%= yield %> + + diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/views/layouts/mailer.html.erb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/views/layouts/mailer.html.erb new file mode 100644 index 000000000..3aac9002e --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/views/layouts/mailer.text.erb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/views/layouts/mailer.text.erb new file mode 100644 index 000000000..37f0bddbd --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/views/pwa/manifest.json.erb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/views/pwa/manifest.json.erb new file mode 100644 index 000000000..a3c046e47 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "Dummy", + "icons": [ + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ], + "start_url": "/", + "display": "standalone", + "scope": "/", + "description": "Dummy.", + "theme_color": "red", + "background_color": "red" +} diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/views/pwa/service-worker.js b/packages/forest_admin_datasource_mongoid/spec/dummy/app/views/pwa/service-worker.js new file mode 100644 index 000000000..b3a13fb7b --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/views/pwa/service-worker.js @@ -0,0 +1,26 @@ +// Add a service worker for processing Web Push notifications: +// +// self.addEventListener("push", async (event) => { +// const { title, options } = await event.data.json() +// event.waitUntil(self.registration.showNotification(title, options)) +// }) +// +// self.addEventListener("notificationclick", function(event) { +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: "window" }).then((clientList) => { +// for (let i = 0; i < clientList.length; i++) { +// let client = clientList[i] +// let clientPath = (new URL(client.url)).pathname +// +// if (clientPath == event.notification.data.path && "focus" in client) { +// return client.focus() +// } +// } +// +// if (clients.openWindow) { +// return clients.openWindow(event.notification.data.path) +// } +// }) +// ) +// }) diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/bin/brakeman b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/brakeman new file mode 100755 index 000000000..ace1c9ba0 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/bin/bundle b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/bundle new file mode 100755 index 000000000..50da5fdf9 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/bundle @@ -0,0 +1,109 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'bundle' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "rubygems" + +m = Module.new do + module_function + + def invoked_as_script? + File.expand_path($0) == File.expand_path(__FILE__) + end + + def env_var_version + ENV["BUNDLER_VERSION"] + end + + def cli_arg_version + return unless invoked_as_script? # don't want to hijack other binstubs + return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` + bundler_version = nil + update_index = nil + ARGV.each_with_index do |a, i| + if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN) + bundler_version = a + end + next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ + bundler_version = $1 + update_index = i + end + bundler_version + end + + def gemfile + gemfile = ENV["BUNDLE_GEMFILE"] + return gemfile if gemfile && !gemfile.empty? + + File.expand_path("../Gemfile", __dir__) + end + + def lockfile + lockfile = + case File.basename(gemfile) + when "gems.rb" then gemfile.sub(/\.rb$/, ".locked") + else "#{gemfile}.lock" + end + File.expand_path(lockfile) + end + + def lockfile_version + return unless File.file?(lockfile) + lockfile_contents = File.read(lockfile) + return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ + Regexp.last_match(1) + end + + def bundler_requirement + @bundler_requirement ||= + env_var_version || + cli_arg_version || + bundler_requirement_for(lockfile_version) + end + + def bundler_requirement_for(version) + return "#{Gem::Requirement.default}.a" unless version + + bundler_gem_version = Gem::Version.new(version) + + bundler_gem_version.approximate_recommendation + end + + def load_bundler! + ENV["BUNDLE_GEMFILE"] ||= gemfile + + activate_bundler + end + + def activate_bundler + gem_error = activation_error_handling do + gem "bundler", bundler_requirement + end + return if gem_error.nil? + require_error = activation_error_handling do + require "bundler/version" + end + return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) + warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" + exit 42 + end + + def activation_error_handling + yield + nil + rescue StandardError, LoadError => e + e + end +end + +m.load_bundler! + +if m.invoked_as_script? + load Gem.bin_path("bundler", "bundle") +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/bin/dev b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/dev new file mode 100755 index 000000000..5f91c2054 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/dev @@ -0,0 +1,2 @@ +#!/usr/bin/env ruby +exec "./bin/rails", "server", *ARGV diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/bin/docker-entrypoint b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/docker-entrypoint new file mode 100755 index 000000000..1ff59e0c6 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/docker-entrypoint @@ -0,0 +1,9 @@ +#!/bin/bash -e + +# Enable jemalloc for reduced memory usage and latency. +if [ -z "${LD_PRELOAD+x}" ]; then + LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit) + export LD_PRELOAD +fi + +exec "${@}" diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/bin/kamal b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/kamal new file mode 100755 index 000000000..cbe59b95e --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/kamal @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'kamal' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("kamal", "kamal") diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/bin/rails b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/rails new file mode 100755 index 000000000..efc037749 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/bin/rake b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/rake new file mode 100755 index 000000000..4fbf10b96 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/bin/rubocop b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/rubocop new file mode 100755 index 000000000..40330c0ff --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# explicit rubocop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/bin/setup b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/setup new file mode 100755 index 000000000..2285724c4 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/setup @@ -0,0 +1,26 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system("bundle check") || system!("bundle install") + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/bin/thrust b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/thrust new file mode 100755 index 000000000..36bde2d83 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config.ru b/packages/forest_admin_datasource_mongoid/spec/dummy/config.ru new file mode 100644 index 000000000..4a3c09a68 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/application.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/config/application.rb new file mode 100644 index 000000000..895672355 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/application.rb @@ -0,0 +1,44 @@ +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +require "active_job/railtie" +# require "active_record/railtie" +# require "active_storage/engine" +require "action_controller/railtie" +require "action_mailer/railtie" +# require "action_mailbox/engine" +# require "action_text/engine" +require "action_view/railtie" +# require "action_cable/engine" +# require "rails/test_unit/railtie" + +require "mongoid" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module Dummy + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 8.0 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + + # Don't generate system test files. + config.generators.system_tests = nil + end +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/boot.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/config/boot.rb new file mode 100644 index 000000000..b66946a79 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/boot.rb @@ -0,0 +1,11 @@ +# ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) +# +# require "bundler/setup" # Set up gems listed in the Gemfile. +# require "bootsnap/setup" # Speed up boot time by caching expensive operations. + +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) + +require 'bundler/setup' # Configure les gems listés dans Gemfile. + +# Désactiver Bootsnap pour l'environnement 'test' +require 'bootsnap/setup' unless ENV['RAILS_ENV'] == 'test' \ No newline at end of file diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/credentials.yml.enc b/packages/forest_admin_datasource_mongoid/spec/dummy/config/credentials.yml.enc new file mode 100644 index 000000000..fab7b7e48 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/credentials.yml.enc @@ -0,0 +1 @@ +R0VNKVrXgL22fa+evm2OidPP8/falmZyi1YZLZa7uQmjo3LIfl4Zv2YE5+K+w5AHaR+jf1RINNTEs7DOlJh9M/so8A9bySZ1zBnpv9iWzUOGtyFbFyN06kSImiCHlooFrsgNX7S4kqW/ZDCeP5eH/8YbXiOHG5vMcTEdrensEbqasqW4IbqnXF0E5RNp0J04tqvgs0k+qp+WLEG3SUaun4TKNpP/W4DS0a2CGkSXlOm1UXVGaZ7yCiQZO1kIVXQcjlZksFDDpY9oYBFdjpQRVD8svk2RnrLKYv16+8K9k3/hJwYVpc04OQPwNRviSppebRdwPXAoD+z5rKhguQh2BvDyrE7qGLETq4EaaSEOKp+oBRpPKncG08xj1h6B5WjS3gbVRtsxxiKJ8uiSCyVJlO4ItH3kkbKp5Sg2Ld8w/zQC+BhSHRA2P0uzkmPKBQwoBoTeYxDtcbBAiB/KjK06KrzqtVLDw9YNRA4sFh4lz+UleFEpbTjv/Wnj--LWNW9y3cV+trTB04--91i9keCJbhjOKPmgPRBI1w== \ No newline at end of file diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/deploy.yml b/packages/forest_admin_datasource_mongoid/spec/dummy/config/deploy.yml new file mode 100644 index 000000000..861f1da3a --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/deploy.yml @@ -0,0 +1,102 @@ +# Name of your application. Used to uniquely configure containers. +service: dummy + +# Name of the container image. +image: your-user/dummy + +# Deploy to these servers. +servers: + web: + - 192.168.0.1 + # job: + # hosts: + # - 192.168.0.1 + # cmd: bin/jobs + +# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. +# Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. +# +# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. +proxy: + ssl: true + host: app.example.com + +# Credentials for your image host. +registry: + # Specify the registry server, if you're not using Docker Hub + # server: registry.digitalocean.com / ghcr.io / ... + username: your-user + + # Always use an access token rather than real password when possible. + password: + - KAMAL_REGISTRY_PASSWORD + +# Inject ENV variables into containers (secrets come from .kamal/secrets). +env: + secret: + - RAILS_MASTER_KEY + # clear: + # # Set number of cores available to the application on each server (default: 1). + # WEB_CONCURRENCY: 2 + + # # Match this to any external database server to configure Active Record correctly + # DB_HOST: 192.168.0.2 + + # # Log everything from Rails + # RAILS_LOG_LEVEL: debug + +# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: +# "bin/kamal logs -r job" will tail logs from the first server in the job section. +aliases: + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs -f + dbc: app exec --interactive --reuse "bin/rails dbconsole" + + +# Bridge fingerprinted assets, like JS and CSS, between versions to avoid +# hitting 404 on in-flight requests. Combines all files from new and old +# version inside the asset_path. +asset_path: /rails/public/assets + +# Configure the image builder. +builder: + arch: amd64 + + # # Build image via remote server (useful for faster amd64 builds on arm64 computers) + # remote: ssh://docker@docker-builder-server + # + # # Pass arguments and secrets to the Docker build process + # args: + # RUBY_VERSION: 3.3.1 + # secrets: + # - GITHUB_TOKEN + # - RAILS_MASTER_KEY + +# Use a different ssh user than root +# ssh: +# user: app + +# Use accessory services (secrets come from .kamal/secrets). +# accessories: +# db: +# image: mysql:8.0 +# host: 192.168.0.2 +# # Change to 3306 to expose port to the world instead of just local network. +# port: "127.0.0.1:3306:3306" +# env: +# clear: +# MYSQL_ROOT_HOST: '%' +# secret: +# - MYSQL_ROOT_PASSWORD +# files: +# - config/mysql/production.cnf:/etc/mysql/my.cnf +# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql +# directories: +# - data:/var/lib/mysql +# redis: +# image: redis:7.0 +# host: 192.168.0.2 +# port: 6379 +# directories: +# - data:/data diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/environment.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/config/environment.rb new file mode 100644 index 000000000..cac531577 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/environments/development.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/config/environments/development.rb new file mode 100644 index 000000000..ff83a6805 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/environments/development.rb @@ -0,0 +1,57 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + # Make template changes take effect immediately. + config.action_mailer.perform_caching = false + + # Set localhost to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/environments/production.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/config/environments/production.rb new file mode 100644 index 000000000..51c00ed7b --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/environments/production.rb @@ -0,0 +1,80 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + config.force_ssl = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [ :request_id ] + config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + + # Change to "debug" to log everything (including potentially personally-identifiable information!) + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + # config.cache_store = :mem_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + # config.active_job.queue_adapter = :resque + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit. + # config.action_mailer.smtp_settings = { + # user_name: Rails.application.credentials.dig(:smtp, :user_name), + # password: Rails.application.credentials.dig(:smtp, :password), + # address: "smtp.example.com", + # port: 587, + # authentication: :plain + # } + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/environments/test.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/config/environments/test.rb new file mode 100644 index 000000000..0424d0f56 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/environments/test.rb @@ -0,0 +1,50 @@ +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with cache-control for performance. + config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + config.cache_store = :null_store + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/assets.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/assets.rb new file mode 100644 index 000000000..68fd3a0a4 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/assets.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +# Rails.application.config.assets.version = "1.0" + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/content_security_policy.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/content_security_policy.rb new file mode 100644 index 000000000..b3076b38f --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/content_security_policy.rb @@ -0,0 +1,25 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/filter_parameter_logging.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/filter_parameter_logging.rb new file mode 100644 index 000000000..c0b717f7e --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc +] diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/inflections.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/inflections.rb new file mode 100644 index 000000000..3860f659e --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/mongoid.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/mongoid.rb new file mode 100644 index 000000000..a1e2e9adf --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/initializers/mongoid.rb @@ -0,0 +1,13 @@ +if defined?(Mongoid) + Mongoid.configure do |config| + target_version = "9.0" + + # Load Mongoid behavior defaults. This automatically sets + # features flags (refer to documentation) + config.load_defaults target_version + + # Optional: Set additional configuration values here + end +else + Rails.logger.warn("Mongoid is not loaded. Please ensure the gem is installed and required.") +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/locales/en.yml b/packages/forest_admin_datasource_mongoid/spec/dummy/config/locales/en.yml new file mode 100644 index 000000000..6c349ae5e --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/master.key b/packages/forest_admin_datasource_mongoid/spec/dummy/config/master.key new file mode 100644 index 000000000..e700d00ad --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/master.key @@ -0,0 +1 @@ +f34fd510738f1031d5aa20a337b1bbff \ No newline at end of file diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/mongoid.yml b/packages/forest_admin_datasource_mongoid/spec/dummy/config/mongoid.yml new file mode 100644 index 000000000..92ec6a4a1 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/mongoid.yml @@ -0,0 +1,8 @@ +test: + clients: + default: + database: dummy_test + hosts: + - localhost:27017 + options: + server_selection_timeout: 5 diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/puma.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/config/puma.rb new file mode 100644 index 000000000..787e4ce98 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/puma.rb @@ -0,0 +1,38 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. +# +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/routes.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/config/routes.rb new file mode 100644 index 000000000..48254e88e --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/routes.rb @@ -0,0 +1,14 @@ +Rails.application.routes.draw do + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", as: :rails_health_check + + # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) + # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest + # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + + # Defines the root path route ("/") + # root "posts#index" +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/lib/tasks/.keep b/packages/forest_admin_datasource_mongoid/spec/dummy/lib/tasks/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/public/400.html b/packages/forest_admin_datasource_mongoid/spec/dummy/public/400.html new file mode 100644 index 000000000..282dbc8cc --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/public/400.html @@ -0,0 +1,114 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/public/404.html b/packages/forest_admin_datasource_mongoid/spec/dummy/public/404.html new file mode 100644 index 000000000..c0670bc87 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/public/404.html @@ -0,0 +1,114 @@ + + + + + + + The page you were looking for doesn’t exist (404 Not found) + + + + + + + + + + + + + +
+
+ +
+
+

The page you were looking for doesn’t exist. You may have mistyped the address or the page may have moved. If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/public/406-unsupported-browser.html b/packages/forest_admin_datasource_mongoid/spec/dummy/public/406-unsupported-browser.html new file mode 100644 index 000000000..9532a9ccd --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/public/406-unsupported-browser.html @@ -0,0 +1,114 @@ + + + + + + + Your browser is not supported (406 Not Acceptable) + + + + + + + + + + + + + +
+
+ +
+
+

Your browser is not supported.
Please upgrade your browser to continue.

+
+
+ + + + diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/public/422.html b/packages/forest_admin_datasource_mongoid/spec/dummy/public/422.html new file mode 100644 index 000000000..8bcf06014 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/public/422.html @@ -0,0 +1,114 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
+
+ +
+
+

The change you wanted was rejected. Maybe you tried to change something you didn’t have access to. If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/public/500.html b/packages/forest_admin_datasource_mongoid/spec/dummy/public/500.html new file mode 100644 index 000000000..d77718c3a --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/public/500.html @@ -0,0 +1,114 @@ + + + + + + + We’re sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We’re sorry, but something went wrong.
If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/public/icon.png b/packages/forest_admin_datasource_mongoid/spec/dummy/public/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c4c9dbfbbd2f7c1421ffd5727188146213abbcef GIT binary patch literal 4166 zcmd6qU;WFw?|v@m)Sk^&NvB8tcujdV-r1b=i(NJxn&7{KTb zX$3(M+3TP2o^#KAo{#tIjl&t~(8D-k004kqPglzn0HFG(Q~(I*AKsD#M*g7!XK0T7 zN6P7j>HcT8rZgKl$v!xr806dyN19Bd4C0x_R*I-a?#zsTvb_89cyhuC&T**i|Rc zq5b8M;+{8KvoJ~uj9`u~d_f6`V&3+&ZX9x5pc8s)d175;@pjm(?dapmBcm0&vl9+W zx1ZD2o^nuyUHWj|^A8r>lUorO`wFF;>9XL-Jy!P}UXC{(z!FO%SH~8k`#|9;Q|eue zqWL0^Bp(fg_+Pkm!fDKRSY;+^@BF?AJE zCUWpXPst~hi_~u)SzYBDZroR+Z4xeHIlm_3Yc_9nZ(o_gg!jDgVa=E}Y8uDgem9`b zf=mfJ_@(BXSkW53B)F2s!&?_R4ptb1fYXlF++@vPhd=marQgEGRZS@B4g1Mu?euknL= z67P~tZ?*>-Hmi7GwlisNHHJDku-dSm7g@!=a}9cSL6Pa^w^2?&?$Oi8ibrr>w)xqx zOH_EMU@m05)9kuNR>>4@H%|){U$^yvVQ(YgOlh;5oU_-vivG-p4=LrN-k7D?*?u1u zsWly%tfAzKd6Fb=`eU2un_uaTXmcT#tlOL+aRS=kZZf}A7qT8lvcTx~7j` z*b>=z)mwg7%B2_!D0!1IZ?Nq{^Y$uI4Qx*6T!E2Col&2{k?ImCO=dD~A&9f9diXy^$x{6CwkBimn|1E09 zAMSezYtiL?O6hS37KpvDM?22&d{l)7h-!F)C-d3j8Z`c@($?mfd{R82)H>Qe`h{~G z!I}(2j(|49{LR?w4Jspl_i!(4T{31|dqCOpI52r5NhxYV+cDAu(xp*4iqZ2e-$YP= zoFOPmm|u*7C?S{Fp43y+V;>~@FFR76bCl@pTtyB93vNWy5yf;HKr8^0d7&GVIslYm zo3Tgt@M!`8B6IW&lK{Xk>%zp41G%`(DR&^u z5^pwD4>E6-w<8Kl2DzJ%a@~QDE$(e87lNhy?-Qgep!$b?5f7+&EM7$e>|WrX+=zCb z=!f5P>MxFyy;mIRxjc(H*}mceXw5a*IpC0PEYJ8Y3{JdoIW)@t97{wcUB@u+$FCCO z;s2Qe(d~oJC^`m$7DE-dsha`glrtu&v&93IZadvl_yjp!c89>zo;Krk+d&DEG4?x$ zufC1n+c1XD7dolX1q|7}uelR$`pT0Z)1jun<39$Sn2V5g&|(j~Z!wOddfYiZo7)A< z!dK`aBHOOk+-E_xbWCA3VR-+o$i5eO9`rMI#p_0xQ}rjEpGW;U!&&PKnivOcG(|m9 z!C8?WC6nCXw25WVa*eew)zQ=h45k8jSIPbq&?VE{oG%?4>9rwEeB4&qe#?-y_es4c|7ufw%+H5EY#oCgv!Lzv291#-oNlX~X+Jl5(riC~r z=0M|wMOP)Tt8@hNg&%V@Z9@J|Q#K*hE>sr6@oguas9&6^-=~$*2Gs%h#GF@h)i=Im z^iKk~ipWJg1VrvKS;_2lgs3n1zvNvxb27nGM=NXE!D4C!U`f*K2B@^^&ij9y}DTLB*FI zEnBL6y{jc?JqXWbkIZd7I16hA>(f9T!iwbIxJj~bKPfrO;>%*5nk&Lf?G@c2wvGrY&41$W{7HM9+b@&XY@>NZM5s|EK_Dp zQX60CBuantx>|d#DsaZ*8MW(we|#KTYZ=vNa#d*DJQe6hr~J6{_rI#?wi@s|&O}FR zG$kfPxheXh1?IZ{bDT-CWB4FTvO-k5scW^mi8?iY5Q`f8JcnnCxiy@m@D-%lO;y0pTLhh6i6l@x52j=#^$5_U^os}OFg zzdHbo(QI`%9#o*r8GCW~T3UdV`szO#~)^&X_(VW>o~umY9-ns9-V4lf~j z`QBD~pJ4a#b`*6bJ^3RS5y?RAgF7K5$ll97Y8#WZduZ`j?IEY~H(s^doZg>7-tk*t z4_QE1%%bb^p~4F5SB$t2i1>DBG1cIo;2(xTaj*Y~hlM{tSDHojL-QPg%Mo%6^7FrpB*{ z4G0@T{-77Por4DCMF zB_5Y~Phv%EQ64W8^GS6h?x6xh;w2{z3$rhC;m+;uD&pR74j+i22P5DS-tE8ABvH(U~indEbBUTAAAXfHZg5QpB@TgV9eI<)JrAkOI z8!TSOgfAJiWAXeM&vR4Glh;VxH}WG&V$bVb`a`g}GSpwggti*&)taV1@Ak|{WrV|5 zmNYx)Ans=S{c52qv@+jmGQ&vd6>6yX6IKq9O$3r&0xUTdZ!m1!irzn`SY+F23Rl6# zFRxws&gV-kM1NX(3(gnKpGi0Q)Dxi~#?nyzOR9!en;Ij>YJZVFAL*=R%7y%Mz9hU% zs>+ZB?qRmZ)nISx7wxY)y#cd$iaC~{k0avD>BjyF1q^mNQ1QcwsxiTySe<6C&cC6P zE`vwO9^k-d`9hZ!+r@Jnr+MF*2;2l8WjZ}DrwDUHzSF{WoG zucbSWguA!3KgB3MU%HH`R;XqVv0CcaGq?+;v_A5A2kpmk5V%qZE3yzQ7R5XWhq=eR zyUezH=@V)y>L9T-M-?tW(PQYTRBKZSVb_!$^H-Pn%ea;!vS_?M<~Tm>_rWIW43sPW z=!lY&fWc1g7+r?R)0p8(%zp&vl+FK4HRkns%BW+Up&wK8!lQ2~bja|9bD12WrKn#M zK)Yl9*8$SI7MAwSK$%)dMd>o+1UD<2&aQMhyjS5R{-vV+M;Q4bzl~Z~=4HFj_#2V9 zB)Gfzx3ncy@uzx?yzi}6>d%-?WE}h7v*w)Jr_gBl!2P&F3DX>j_1#--yjpL%<;JMR z*b70Gr)MMIBWDo~#<5F^Q0$VKI;SBIRneuR7)yVsN~A9I@gZTXe)E?iVII+X5h0~H zx^c(fP&4>!*q>fb6dAOC?MI>Cz3kld#J*;uik+Ps49cwm1B4 zZc1|ZxYyTv;{Z!?qS=D)sgRKx^1AYf%;y_V&VgZglfU>d+Ufk5&LV$sKv}Hoj+s; xK3FZRYdhbXT_@RW*ff3@`D1#ps#~H)p+y&j#(J|vk^lW{fF9OJt5(B-_&*Xgn9~3N literal 0 HcmV?d00001 diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/public/icon.svg b/packages/forest_admin_datasource_mongoid/spec/dummy/public/icon.svg new file mode 100644 index 000000000..04b34bf83 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/public/robots.txt b/packages/forest_admin_datasource_mongoid/spec/dummy/public/robots.txt new file mode 100644 index 000000000..c19f78ab6 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/script/.keep b/packages/forest_admin_datasource_mongoid/spec/dummy/script/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb b/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb index e46fda326..0814b456b 100644 --- a/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb +++ b/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb @@ -2,8 +2,7 @@ require 'simplecov_json_formatter' require 'simplecov-html' require 'forest_admin_datasource_mongoid' -require 'active_record' -require 'database_cleaner-active_record' +require 'mongoid-rspec' SimpleCov.formatters = [SimpleCov::Formatter::JSONFormatter, SimpleCov::Formatter::HTMLFormatter] SimpleCov.start do @@ -11,34 +10,21 @@ end ENV['RAILS_ENV'] ||= 'test' -require File.expand_path('dummy/config/environment', __dir__) -Rails.application.eager_load! -ActiveRecord::Migration.verbose = false -ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: 'db/database.db') -ActiveRecord::MigrationContext.new("spec/dummy/db/migrate").migrate +require File.expand_path('dummy/config/environment', __dir__) +Mongoid.load!(File.expand_path('dummy/config/mongoid.yml', __dir__), :test) RSpec.configure do |config| - config.example_status_persistence_file_path = ".rspec_status" + config.include Mongoid::Matchers, type: :model - config.expect_with :rspec do |c| - c.syntax = :expect - end - - # database_cleaner config config.before(:suite) do - DatabaseCleaner.clean_with :truncation, except: %w[ar_internal_metadata] - end - - config.before do - DatabaseCleaner.strategy = :transaction - end - - config.before do - DatabaseCleaner.start + Mongoid.purge! end config.after do - DatabaseCleaner.clean + Mongoid.purge! end + + # config.infer_spec_type_from_file_location! + # config.filter_rails_from_backtrace! end From 2a5a93db00ed5071b29d0551b968870e1a39dfc3 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 21 Jan 2025 10:00:36 +0100 Subject: [PATCH 012/104] chore: clean dummy rails --- .../.gitignore | 4 + .../spec/dummy/.github/dependabot.yml | 12 -- .../spec/dummy/.github/workflows/ci.yml | 39 ------- .../dummy/.kamal/hooks/docker-setup.sample | 3 - .../dummy/.kamal/hooks/post-deploy.sample | 14 --- .../.kamal/hooks/post-proxy-reboot.sample | 3 - .../spec/dummy/.kamal/hooks/pre-build.sample | 51 -------- .../dummy/.kamal/hooks/pre-connect.sample | 47 -------- .../spec/dummy/.kamal/hooks/pre-deploy.sample | 109 ------------------ .../.kamal/hooks/pre-proxy-reboot.sample | 3 - .../spec/dummy/.kamal/secrets | 17 --- 11 files changed, 4 insertions(+), 298 deletions(-) delete mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/.github/dependabot.yml delete mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/.github/workflows/ci.yml delete mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/docker-setup.sample delete mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/post-deploy.sample delete mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/post-proxy-reboot.sample delete mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-build.sample delete mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-connect.sample delete mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-deploy.sample delete mode 100755 packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-proxy-reboot.sample delete mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/secrets diff --git a/packages/forest_admin_datasource_mongoid/.gitignore b/packages/forest_admin_datasource_mongoid/.gitignore index 971206634..c87832b7e 100644 --- a/packages/forest_admin_datasource_mongoid/.gitignore +++ b/packages/forest_admin_datasource_mongoid/.gitignore @@ -13,3 +13,7 @@ # rspec failure tracking .rspec_status + +# GEM +Gemfile.lock +Gemfile-test.lock \ No newline at end of file diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.github/dependabot.yml b/packages/forest_admin_datasource_mongoid/spec/dummy/.github/dependabot.yml deleted file mode 100644 index f0527e6be..000000000 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/.github/dependabot.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: 2 -updates: -- package-ecosystem: bundler - directory: "/" - schedule: - interval: daily - open-pull-requests-limit: 10 -- package-ecosystem: github-actions - directory: "/" - schedule: - interval: daily - open-pull-requests-limit: 10 diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.github/workflows/ci.yml b/packages/forest_admin_datasource_mongoid/spec/dummy/.github/workflows/ci.yml deleted file mode 100644 index fb0f4ea1e..000000000 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/.github/workflows/ci.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: [ main ] - -jobs: - scan_ruby: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true - - - name: Scan for common Rails security vulnerabilities using static analysis - run: bin/brakeman --no-pager - - lint: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: .ruby-version - bundler-cache: true - - - name: Lint code for consistent style - run: bin/rubocop -f github - diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/docker-setup.sample b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/docker-setup.sample deleted file mode 100755 index 2fb07d7d7..000000000 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/docker-setup.sample +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -echo "Docker set up on $KAMAL_HOSTS..." diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/post-deploy.sample b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/post-deploy.sample deleted file mode 100755 index 75efafc10..000000000 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/post-deploy.sample +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh - -# A sample post-deploy hook -# -# These environment variables are available: -# KAMAL_RECORDED_AT -# KAMAL_PERFORMER -# KAMAL_VERSION -# KAMAL_HOSTS -# KAMAL_ROLE (if set) -# KAMAL_DESTINATION (if set) -# KAMAL_RUNTIME - -echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/post-proxy-reboot.sample b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/post-proxy-reboot.sample deleted file mode 100755 index 1435a677f..000000000 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/post-proxy-reboot.sample +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -echo "Rebooted kamal-proxy on $KAMAL_HOSTS" diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-build.sample b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-build.sample deleted file mode 100755 index f87d81130..000000000 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-build.sample +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/sh - -# A sample pre-build hook -# -# Checks: -# 1. We have a clean checkout -# 2. A remote is configured -# 3. The branch has been pushed to the remote -# 4. The version we are deploying matches the remote -# -# These environment variables are available: -# KAMAL_RECORDED_AT -# KAMAL_PERFORMER -# KAMAL_VERSION -# KAMAL_HOSTS -# KAMAL_ROLE (if set) -# KAMAL_DESTINATION (if set) - -if [ -n "$(git status --porcelain)" ]; then - echo "Git checkout is not clean, aborting..." >&2 - git status --porcelain >&2 - exit 1 -fi - -first_remote=$(git remote) - -if [ -z "$first_remote" ]; then - echo "No git remote set, aborting..." >&2 - exit 1 -fi - -current_branch=$(git branch --show-current) - -if [ -z "$current_branch" ]; then - echo "Not on a git branch, aborting..." >&2 - exit 1 -fi - -remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) - -if [ -z "$remote_head" ]; then - echo "Branch not pushed to remote, aborting..." >&2 - exit 1 -fi - -if [ "$KAMAL_VERSION" != "$remote_head" ]; then - echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 - exit 1 -fi - -exit 0 diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-connect.sample b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-connect.sample deleted file mode 100755 index 18e61d7e5..000000000 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-connect.sample +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env ruby - -# A sample pre-connect check -# -# Warms DNS before connecting to hosts in parallel -# -# These environment variables are available: -# KAMAL_RECORDED_AT -# KAMAL_PERFORMER -# KAMAL_VERSION -# KAMAL_HOSTS -# KAMAL_ROLE (if set) -# KAMAL_DESTINATION (if set) -# KAMAL_RUNTIME - -hosts = ENV["KAMAL_HOSTS"].split(",") -results = nil -max = 3 - -elapsed = Benchmark.realtime do - results = hosts.map do |host| - Thread.new do - tries = 1 - - begin - Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) - rescue SocketError - if tries < max - puts "Retrying DNS warmup: #{host}" - tries += 1 - sleep rand - retry - else - puts "DNS warmup failed: #{host}" - host - end - end - - tries - end - end.map(&:value) -end - -retries = results.sum - hosts.size -nopes = results.count { |r| r == max } - -puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-deploy.sample b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-deploy.sample deleted file mode 100755 index 1b280c719..000000000 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-deploy.sample +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env ruby - -# A sample pre-deploy hook -# -# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. -# -# Fails unless the combined status is "success" -# -# These environment variables are available: -# KAMAL_RECORDED_AT -# KAMAL_PERFORMER -# KAMAL_VERSION -# KAMAL_HOSTS -# KAMAL_COMMAND -# KAMAL_SUBCOMMAND -# KAMAL_ROLE (if set) -# KAMAL_DESTINATION (if set) - -# Only check the build status for production deployments -if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" - exit 0 -end - -require "bundler/inline" - -# true = install gems so this is fast on repeat invocations -gemfile(true, quiet: true) do - source "https://rubygems.org" - - gem "octokit" - gem "faraday-retry" -end - -MAX_ATTEMPTS = 72 -ATTEMPTS_GAP = 10 - -def exit_with_error(message) - $stderr.puts message - exit 1 -end - -class GithubStatusChecks - attr_reader :remote_url, :git_sha, :github_client, :combined_status - - def initialize - @remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/") - @git_sha = `git rev-parse HEAD`.strip - @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) - refresh! - end - - def refresh! - @combined_status = github_client.combined_status(remote_url, git_sha) - end - - def state - combined_status[:state] - end - - def first_status_url - first_status = combined_status[:statuses].find { |status| status[:state] == state } - first_status && first_status[:target_url] - end - - def complete_count - combined_status[:statuses].count { |status| status[:state] != "pending"} - end - - def total_count - combined_status[:statuses].count - end - - def current_status - if total_count > 0 - "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." - else - "Build not started..." - end - end -end - - -$stdout.sync = true - -puts "Checking build status..." -attempts = 0 -checks = GithubStatusChecks.new - -begin - loop do - case checks.state - when "success" - puts "Checks passed, see #{checks.first_status_url}" - exit 0 - when "failure" - exit_with_error "Checks failed, see #{checks.first_status_url}" - when "pending" - attempts += 1 - end - - exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS - - puts checks.current_status - sleep(ATTEMPTS_GAP) - checks.refresh! - end -rescue Octokit::NotFound - exit_with_error "Build status could not be found" -end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-proxy-reboot.sample b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-proxy-reboot.sample deleted file mode 100755 index 061f8059e..000000000 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/hooks/pre-proxy-reboot.sample +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/secrets b/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/secrets deleted file mode 100644 index 9a771a398..000000000 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/.kamal/secrets +++ /dev/null @@ -1,17 +0,0 @@ -# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, -# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either -# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. - -# Example of extracting secrets from 1password (or another compatible pw manager) -# SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) -# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS}) -# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS}) - -# Use a GITHUB_TOKEN if private repositories are needed for the image -# GITHUB_TOKEN=$(gh config get -h github.com oauth_token) - -# Grab the registry password from ENV -KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD - -# Improve security by using a password manager. Never check config/master.key into git! -RAILS_MASTER_KEY=$(cat config/master.key) From 16038265ed5208f21b7050f4fb90e8b2647824c7 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 21 Jan 2025 10:28:31 +0100 Subject: [PATCH 013/104] chore: update log mongoid rspec --- .../spec/dummy/config/mongoid.yml | 1 + packages/forest_admin_datasource_mongoid/spec/spec_helper.rb | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/mongoid.yml b/packages/forest_admin_datasource_mongoid/spec/dummy/config/mongoid.yml index 92ec6a4a1..7d3141fed 100644 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/config/mongoid.yml +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/mongoid.yml @@ -6,3 +6,4 @@ test: - localhost:27017 options: server_selection_timeout: 5 + log_level: :error diff --git a/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb b/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb index 0814b456b..74ef91842 100644 --- a/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb +++ b/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb @@ -13,6 +13,8 @@ require File.expand_path('dummy/config/environment', __dir__) Mongoid.load!(File.expand_path('dummy/config/mongoid.yml', __dir__), :test) +Mongoid.logger.level = Logger::ERROR +Mongo::Logger.logger.level = Logger::ERROR RSpec.configure do |config| config.include Mongoid::Matchers, type: :model From e81e71d9651d5e0b81b52438c7cc61fa3f90432f Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Tue, 21 Jan 2025 17:37:33 +0100 Subject: [PATCH 014/104] fix(introspection): field is primary_key only if is not retalted to a relation --- .../lib/forest_admin_datasource_mongoid/collection.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index 461ee199a..f0e246efc 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -54,7 +54,7 @@ def fetch_fields(model, prefix = nil) field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( column_type: get_column_type(column), filter_operators: operators_for_column_type(get_column_type(column)), - is_primary_key: column.object_id_field?, + is_primary_key: column.object_id_field? && column.association.nil?, is_read_only: false, is_sortable: true, default_value: column.object_id_field? ? nil : get_default_value(column), From e0b48c40d07090e9a4134ba29237b7e247954f69 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 22 Jan 2025 11:18:08 +0100 Subject: [PATCH 015/104] chore: add test --- .../collection.rb | 25 ------- .../collection_spec.rb | 74 +++++++++++++++++++ 2 files changed, 74 insertions(+), 25 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index f0e246efc..fdf36f711 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -148,7 +148,6 @@ def fetch_associations # simplify this type of relationship by considering them to be sub-fields add_embedded_fields(association, association.name) when Mongoid::Association::Referenced::HasAndBelongsToMany - # datasource.simulate_habtm(model) foreign_key_of_association = association.klass.reflect_on_all_associations.find do |assoc| assoc.klass == association.inverse_class_name.constantize end&.foreign_key @@ -161,34 +160,10 @@ def fetch_associations origin_key_target: association.primary_key ) ) - else - 'unknown' end end end - # def add_embedded_fields(association, prefix = '', depth = 0) - # fetch_fields(association.klass, prefix) - # association.klass.relations.each do |sub_name, sub_association| - # field_name = "#{prefix}.#{sub_name}" - # - # if depth >= MAX_DEPTH - # make_field(field_name, Hash) - # else - # case sub_association - # when Mongoid::Association::Embedded::EmbedsOne - # make_field(field_name, Hash) - # add_embedded_fields(sub_association, field_name, depth + 1) - # debugger - # when Mongoid::Association::Embedded::EmbedsMany - # make_field(field_name, Array) - # add_embedded_fields(sub_association, "#{field_name}[]", depth + 1) - # else - # make_field(field_name, sub_association.class) - # end - # end - # end - # end def add_embedded_fields(association, prefix = '') return make_field(association.name.to_s, Hash) if association.is_a?(Mongoid::Association::Embedded::EmbedsMany) diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb new file mode 100644 index 000000000..965382600 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + describe Collection do + let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new } + let(:collection_post) { described_class.new(datasource, Post) } + let(:collection_user) { described_class.new(datasource, User) } + let(:collection_departure) { described_class.new(datasource, Departure) } + let(:collection_comment) { described_class.new(datasource, Comment) } + let(:collection_band) { described_class.new(datasource, Band) } + + it 'initializes with the correct model name' do + expect(collection_post.name).to eq('Post') + end + + it 'add all fields of model to the collection' do + expect(collection_post.schema[:fields].keys).to include('_id', + 'created_at', + 'updated_at', + 'title', + 'body', + 'tag_ids', + 'comments', + 'author', + 'tags') + end + + describe 'fetch_associations' do + it 'add all relation of model to the collection' do + expect(collection_post.schema[:fields].keys).to include('comments', 'author', 'tags') + end + + it 'defines the correct association types' do + expect(collection_post.schema[:fields]['comments']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema) + expect(collection_post.schema[:fields]['author']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToOneSchema) + expect(collection_post.schema[:fields]['tags']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema) + expect(collection_user.schema[:fields]['item']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicManyToOneSchema) + expect(collection_departure.schema[:fields]['user']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicOneToOneSchema) + expect(collection_comment.schema[:fields]['post']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + end + + # TODO: UNCOMMENT when embedded relations types are fixed + # context 'when the relation is embedded' do + # it 'add a single field for an embedded many relation' do + # expect(collection_user.schema[:fields]['addresses']).to be_a(ForestAdminDatasourceToolkit::Schema::ColumnSchema) + # end + # + # it 'add a single field for each key of an embedded one relation' do + # expect(collection_band.schema[:fields].keys).to include('label._id', 'label.name', 'label.section') + # expect(collection_band.schema[:fields]['label._id']).to be_a(ForestAdminDatasourceToolkit::Schema::ColumnSchema) + # expect(collection_band.schema[:fields]['label.name']).to be_a(ForestAdminDatasourceToolkit::Schema::ColumnSchema) + # expect(collection_band.schema[:fields]['label.section']).to be_a(ForestAdminDatasourceToolkit::Schema::ColumnSchema) + # end + # end + end + + it 'serializes the schema to JSON' do + json_schema = collection_post.schema.to_json + expect { JSON.parse(json_schema) }.not_to raise_error + expect(JSON.parse(json_schema)['fields'].keys).to include('title', 'body', 'comments') + end + + # describe '#list' do + # it 'returns a serialized list of records' do + # allow(Utils::Query).to receive_message_chain(:new, :get).and_return([Post.new(title: 'Test', body: 'Body')]) + # allow(Utils::MongoidSerializer).to receive(:new).and_return(double(to_hash: { title: 'Test', body: 'Body' })) + # + # result = collection.list(nil, {}, nil) + # + # expect(result).to eq([{ title: 'Test', body: 'Body' }]) + # end + # end + end +end From 040b7a1acc2ec159c11840e5dea94d97791b9431 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Wed, 22 Jan 2025 15:57:15 +0100 Subject: [PATCH 016/104] fix(introspection): add embedded fields as column with composite type --- .../collection.rb | 52 +++++-------------- .../parser/column.rb | 21 +++++++- 2 files changed, 32 insertions(+), 41 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index fdf36f711..cb8373409 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -49,7 +49,8 @@ def format_model_name(class_name) class_name.gsub('::', '__') end - def fetch_fields(model, prefix = nil) + def fetch_fields(model) + # Standard fields model.fields.each do |column_name, column| field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( column_type: get_column_type(column), @@ -62,11 +63,17 @@ def fetch_fields(model, prefix = nil) validations: get_validations(column) ) - if prefix - add_field("#{prefix}.#{column_name}", field) - else - add_field(column_name, field) - end + add_field(column_name, field) + end + + # Embedded field (EmbedsMany and EmbedsOne) + get_embedded_fields(model).each do |column_name, column| + field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( + column_type: get_column_type(column), + is_primary_key: false, + is_sortable: false + ) + add_field(column_name, field) end end @@ -141,12 +148,6 @@ def fetch_associations ) ) end - when Mongoid::Association::Embedded::EmbedsMany - # simplify this type of relationship by considering them to be sub-fields and encapsulating it in an array - add_embedded_fields(association, "#{association.name}.[]") - when Mongoid::Association::Embedded::EmbedsOne - # simplify this type of relationship by considering them to be sub-fields - add_embedded_fields(association, association.name) when Mongoid::Association::Referenced::HasAndBelongsToMany foreign_key_of_association = association.klass.reflect_on_all_associations.find do |assoc| assoc.klass == association.inverse_class_name.constantize @@ -163,32 +164,5 @@ def fetch_associations end end end - - def add_embedded_fields(association, prefix = '') - return make_field(association.name.to_s, Hash) if association.is_a?(Mongoid::Association::Embedded::EmbedsMany) - - fetch_fields(association.klass, prefix) - - association.klass.relations.each do |sub_name, sub_association| - field_name = "#{prefix}.#{sub_name}" - - next if sub_association.is_a?(Mongoid::Association::Embedded::EmbeddedIn) - - if sub_association.is_a?(Mongoid::Association::Embedded::EmbedsOne) || - sub_association.is_a?(Mongoid::Association::Embedded::EmbedsMany) - make_field(field_name, Hash) - else - make_field(field_name, sub_association.class) - end - end - end - - def make_field(name, type) - schema[:fields][name] = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( - column_type: type.to_s, - is_primary_key: false, - is_sortable: true - ) - end end end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb index 8ad16797f..12bc8e32f 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb @@ -26,9 +26,21 @@ module Column }.freeze def get_column_type(column) - return 'String' if column.foreign_key? + if column.is_a? Mongoid::Fields::Standard + return TYPES[column.type.to_s] || 'String' + elsif column.is_a? Mongoid::Fields::ForeignKey + return 'String' + elsif column.respond_to?(:embedded?) && column.embedded? + model = column.class_name.constantize + fields = model.fields.merge(get_embedded_fields(model)) + type = fields.reduce({}) do |memo, (name, column)| + memo.merge({ name => get_column_type(column) }) + end + + return column.is_a?(Mongoid::Association::Embedded::EmbedsMany) ? [type] : type + end - TYPES[column.type.to_s] || 'String' + 'String' end def get_default_value(column) @@ -41,6 +53,11 @@ def get_default_value(column) nil end + def get_embedded_fields(model) + embedded_class = [Mongoid::Association::Embedded::EmbedsMany, Mongoid::Association::Embedded::EmbedsOne] + model.relations.select { |_name, association| embedded_class.include?(association.class) } + end + def operators_for_column_type(type) default_operators = [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL] in_operators = [Operators::IN, Operators::NOT_IN] From 86f00b59b54a19dbae94e1630f75a22802ef4893 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 22 Jan 2025 16:07:15 +0100 Subject: [PATCH 017/104] chore: remove habtm logic --- .../datasource.rb | 59 ++----------------- .../through_collection.rb | 42 ------------- 2 files changed, 6 insertions(+), 95 deletions(-) delete mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/through_collection.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb index 216e0a183..7c9542f3f 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb @@ -10,12 +10,6 @@ def initialize generate end - def simulate_habtm(model) - model.reflect_on_all_associations(:has_and_belongs_to_many).each do |association| - make_through_model(model, association) unless collections.key?(create_through_model_name(model, association)) - end - end - private def generate @@ -32,55 +26,14 @@ def embedded_in_relation?(klass) klass.relations.any? { |_name, association| association.is_a?(Mongoid::Association::Embedded::EmbeddedIn) } end - def make_through_model(model, association) - # make a fake model to represent the through model - Class.new do - include Mongoid::Document - class << self - attr_accessor :name, :collection_name, :primary_key - end - - def self.add_association(name, options) - belongs_to name, required: false, **options - end - end - - options = {} - options[:name] = create_through_model_name(model, association) - options[:collection_name] = options[:name].downcase - - options[:associations] = [ - { - name: model.name.demodulize.underscore.to_sym, - foreign_collection: model.name.gsub('::', '__'), - foreign_key: fetch_foreign_key(model), - foreign_key_target: fetch_primary_key(association.klass) - }, - { - name: association.klass.name.demodulize.underscore.to_sym, - foreign_collection: association.klass.name.gsub('::', '__'), - foreign_key: fetch_foreign_key(association.klass), - foreign_key_target: fetch_primary_key(model) - } - ] - - add_collection(ThroughCollection.new(self, options)) - end - - def create_through_model_name(model, association) - [model, association.klass] - .map { |klass| klass.name.split('::').last } - .sort - .join('_') - end - def fetch_primary_key(klass) - klass.fields.find { |_, field| field.options[:identity] || field.name == '_id' }&.first - end + primary_key = klass.fields.find { |_, field| field.options[:identity] || field.name == '_id' }&.first + unless primary_key + raise(ForestAdminDatasourceToolkit::Exceptions::ForestException, + "Primary key not found for #{klass.name}") + end - def fetch_foreign_key(klass) - klass.fields.find { |_, field| field.options[:identity] }&.first || '_id' - # fetch_primary_key(klass).to_s + primary_key end end end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/through_collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/through_collection.rb deleted file mode 100644 index 0a1f97673..000000000 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/through_collection.rb +++ /dev/null @@ -1,42 +0,0 @@ -module ForestAdminDatasourceMongoid - class ThroughCollection < ForestAdminDatasourceToolkit::Collection - include Parser::Column - include ForestAdminDatasourceToolkit::Components::Query - - attr_reader :model, :options - - def initialize(datasource, options = {}) - # @model = model - # name = format_model_name(@model.name) - @options = options - super(datasource, options[:name]) - add_relations - end - - def add_relations - @options[:associations].each do |association| - add_field( - association[:name].to_s, - ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema.new( - foreign_collection: association[:foreign_collection], - foreign_key: association[:foreign_key], - foreign_key_target: association[:foreign_key_target] - ) - ) - - field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( - column_type: get_column_type(column), - filter_operators: operators_for_column_type(get_column_type(column)), - is_primary_key: column.object_id_field?, - is_read_only: false, - is_sortable: true, - default_value: column.object_id_field? ? nil : get_default_value(column), - enum_values: [], - validations: get_validations(column) - ) - - add_field(column_name, field) - end - end - end -end From 669e8d5c84cc28e6d719c58bce4de0d6c3687c61 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 22 Jan 2025 16:07:42 +0100 Subject: [PATCH 018/104] chore: update config for test --- .../spec/dummy/app/models/not_mongoid_model.rb | 2 ++ .../forest_admin_datasource_mongoid/spec/spec_helper.rb | 8 +++----- 2 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/models/not_mongoid_model.rb diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/not_mongoid_model.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/not_mongoid_model.rb new file mode 100644 index 000000000..18dfa4134 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/not_mongoid_model.rb @@ -0,0 +1,2 @@ +class NotMongoidModel +end diff --git a/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb b/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb index 74ef91842..d404afc70 100644 --- a/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb +++ b/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb @@ -12,7 +12,8 @@ ENV['RAILS_ENV'] ||= 'test' require File.expand_path('dummy/config/environment', __dir__) -Mongoid.load!(File.expand_path('dummy/config/mongoid.yml', __dir__), :test) +Dir[File.expand_path('dummy/app/models/**/*.rb', __dir__)].each { |file| require file } + Mongoid.logger.level = Logger::ERROR Mongo::Logger.logger.level = Logger::ERROR @@ -20,13 +21,10 @@ config.include Mongoid::Matchers, type: :model config.before(:suite) do - Mongoid.purge! + Mongoid.load!(File.expand_path('dummy/config/mongoid.yml', __dir__), :test) end config.after do Mongoid.purge! end - - # config.infer_spec_type_from_file_location! - # config.filter_rails_from_backtrace! end From a13371c31e6b8429386fc0a451c009dc80393177 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 22 Jan 2025 16:24:45 +0100 Subject: [PATCH 019/104] chore: add test for datasource --- .../datasource_spec.rb | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb new file mode 100644 index 000000000..c8f4e7997 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + include ForestAdminDatasourceToolkit::Exceptions + describe Datasource do + let(:datasource) { described_class.new } + + it 'adds collections to the datasource' do + expected = %w[Band Author Tag Comment Departure Post User Team] + + expect(datasource.collections.keys).to match_array(expected) + end + + it 'only loads models that are Mongoid::Document classes' do + expect(datasource.collections.keys).not_to include('NotMongoidModel') + end + + it 'does not load embedded models as collections' do + expect(datasource.collections.keys).not_to include('Address') + end + + it 'does not load any models if no Mongoid::Document classes are present' do + # Simulates the absence of Mongoid models in the application by forcing the each_object method + # to return an empty list. This allows testing the behavior of the Datasource when no Mongoid models are available. + allow(ObjectSpace).to receive(:each_object).and_return([]) + + datasource = described_class.new + expect(datasource.collections.keys).to be_empty + end + + it 'raises an exception if trying to add a collection with an existing name' do + expect { datasource.add_collection(Collection.new(datasource, Post)) } + .to raise_error(ForestException, '🌳🌳🌳 Collection Post already defined in datasource') + end + end +end From 9988ee9e6431d93d2f986bac82d273e2cc87c058 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Thu, 23 Jan 2025 10:48:30 +0100 Subject: [PATCH 020/104] fix(store): fix format data and build filter --- .../routes/abstract_authenticated_route.rb | 2 +- .../lib/forest_admin_agent/routes/resources/store.rb | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb index 2a01d0c02..d612e08f3 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb @@ -11,7 +11,7 @@ def build(args = {}) end def format_attributes(args) - record = args[:params][:data][:attributes] + record = args[:params][:data][:attributes] || {} args[:params][:data][:relationships]&.map do |field, value| schema = @collection.schema[:fields][field] diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/store.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/store.rb index e59891563..b18fb113d 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/store.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/store.rb @@ -19,10 +19,9 @@ def handle_request(args = {}) data = format_attributes(args) record = @collection.create(@caller, data) link_one_to_one_relations(args, record) - - id = Utils::Id.unpack_id(@collection, record['id'], with_key: true) + id = ForestAdminDatasourceToolkit::Utils::Record.primary_keys(@collection, record) filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new( - condition_tree: ConditionTree::ConditionTreeFactory.match_records(@collection, [id]) + condition_tree: ConditionTree::ConditionTreeFactory.match_ids(@collection, [id]) ) records = @collection.list(@caller, filter, ProjectionFactory.all(@collection)) @@ -40,7 +39,7 @@ def handle_request(args = {}) def link_one_to_one_relations(args, record) args[:params][:data][:relationships]&.map do |field, value| schema = @collection.schema[:fields][field] - next unless ['OneToOne', 'PolymorphicOneToOne'].include?(schema.type) + next unless %w[OneToOne PolymorphicOneToOne].include?(schema.type) id = Utils::Id.unpack_id(@collection, value['data']['id'], with_key: true) foreign_collection = @datasource.get_collection(schema.foreign_collection) From 43cdf8f91ad65380d45bced3e0372732278d4b01 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Thu, 23 Jan 2025 11:08:49 +0100 Subject: [PATCH 021/104] fix(relation): format collection name for polyporphic type --- .../lib/forest_admin_datasource_mongoid/parser/relation.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb index 5dc752737..1a1baf7c6 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb @@ -7,7 +7,7 @@ def get_polymorphic_types(relation_name) ObjectSpace.each_object(Class).select { |klass| klass < Mongoid::Document }.each do |model| if model.relations.any? { |_, relation| relation.options[:as] == relation_name.to_sym } primary_key = model.fields.keys.find { |key| model.fields[key].options[:as] == :id } || :_id - types[model.name] = primary_key.to_s + types[format_model_name(model.name)] = primary_key.to_s end end From d570a9406b261c42bff88665d369c8fee73d2423 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Thu, 23 Jan 2025 11:10:01 +0100 Subject: [PATCH 022/104] feat(crud): add support of create, update and delete operations --- .../collection.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index cb8373409..f4dd6493c 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -14,7 +14,7 @@ def initialize(datasource, model) name = format_model_name(@model.name) super(datasource, name) - fetch_fields(@model) + fetch_fields fetch_associations enable_count end @@ -30,17 +30,17 @@ def aggregate(_caller, filter, aggregation, limit = nil) end def create(_caller, data) - # Utils::ActiveRecordSerializer.new(@model.create(data)).to_hash(ProjectionFactory.all(self)) + Utils::MongoidSerializer.new(@model.create(data)).to_hash(ProjectionFactory.all(self)) end def update(_caller, filter, data) - # entity = Utils::Query.new(self, nil, filter).build.first - # entity&.update(data) + entity = Utils::Query.new(self, nil, filter).build.first + entity&.update(data) end def delete(_caller, filter) - # entities = Utils::Query.new(self, nil, filter).build - # entities&.each(&:destroy) + entities = Utils::Query.new(self, nil, filter).build + entities&.each(&:destroy) end private @@ -49,9 +49,9 @@ def format_model_name(class_name) class_name.gsub('::', '__') end - def fetch_fields(model) + def fetch_fields # Standard fields - model.fields.each do |column_name, column| + @model.fields.each do |column_name, column| field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( column_type: get_column_type(column), filter_operators: operators_for_column_type(get_column_type(column)), @@ -67,7 +67,7 @@ def fetch_fields(model) end # Embedded field (EmbedsMany and EmbedsOne) - get_embedded_fields(model).each do |column_name, column| + get_embedded_fields(@model).each do |column_name, column| field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( column_type: get_column_type(column), is_primary_key: false, From eda8aad202d08b54e66e49ceac661470459af26b Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Thu, 23 Jan 2025 16:21:01 +0100 Subject: [PATCH 023/104] feat(serializer): serialize hasOne and belongsTo relations --- .../utils/mongoid_serializer.rb | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb index b3e3eff50..f915e4750 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb @@ -5,7 +5,7 @@ def to_hash(projection = nil) hash_object(object, projection) end - def hash_object(object, _projection = nil, with_associations: true) + def hash_object(object, projection = nil, with_associations: true) hash = {} return if object.nil? @@ -13,27 +13,26 @@ def hash_object(object, _projection = nil, with_associations: true) object.attributes.each do |key, value| hash[key] = value end - # hash.merge! object.attributes if with_associations - # each_association_collection(object, projection) do |association_name, item| - # hash[association_name] = hash_object( - # item, - # projection.relations[association_name], - # with_associations: projection.relations.key?(association_name) - # ) - # end + each_association_collection(object, projection) do |association_name, item| + hash[association_name] = hash_object( + item, + projection.relations[association_name], + with_associations: projection.relations.key?(association_name) + ) + end end hash end - # def each_association_collection(object, projection) - # one_associations = %i[has_one belongs_to] - # object.class.reflect_on_all_associations - # .filter { |a| one_associations.include?(a.macro) && projection.relations.key?(a.name.to_s) } - # .each { |association| yield(association.name.to_s, object.send(association.name.to_s)) } - # end + def each_association_collection(object, projection) + one_associations = [Mongoid::Association::Referenced::HasOne, Mongoid::Association::Referenced::BelongsTo] + object.class.reflect_on_all_associations + .filter { |a| one_associations.include?(a.class) && projection.relations.key?(a.name.to_s) } + .each { |association| yield(association.name.to_s, object.send(association.name.to_s)) } + end end end end From 3b82788ba7d1ebf6e4ff50c97f60c0e0c38bb324 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 23 Jan 2025 16:23:35 +0100 Subject: [PATCH 024/104] chore: test on collection --- .../collection_spec.rb | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb index 965382600..9e4c6904a 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb @@ -39,19 +39,22 @@ module ForestAdminDatasourceMongoid expect(collection_comment.schema[:fields]['post']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) end - # TODO: UNCOMMENT when embedded relations types are fixed - # context 'when the relation is embedded' do - # it 'add a single field for an embedded many relation' do - # expect(collection_user.schema[:fields]['addresses']).to be_a(ForestAdminDatasourceToolkit::Schema::ColumnSchema) - # end - # - # it 'add a single field for each key of an embedded one relation' do - # expect(collection_band.schema[:fields].keys).to include('label._id', 'label.name', 'label.section') - # expect(collection_band.schema[:fields]['label._id']).to be_a(ForestAdminDatasourceToolkit::Schema::ColumnSchema) - # expect(collection_band.schema[:fields]['label.name']).to be_a(ForestAdminDatasourceToolkit::Schema::ColumnSchema) - # expect(collection_band.schema[:fields]['label.section']).to be_a(ForestAdminDatasourceToolkit::Schema::ColumnSchema) - # end - # end + context 'when the relation is embedded' do + it 'add a single field for an embedded many relation' do + expect(collection_user.schema[:fields]['addresses']).to be_a(ForestAdminDatasourceToolkit::Schema::ColumnSchema) + end + + it 'add single field with a composite type' do + expect(collection_band.schema[:fields].keys).to include('label') + expect(collection_band.schema[:fields]['label'].column_type).to eq( + { + '_id' => 'String', + 'name' => 'String', + 'section' => { '_id' => 'String', 'content' => 'String', 'body' => 'String' } + } + ) + end + end end it 'serializes the schema to JSON' do @@ -60,12 +63,13 @@ module ForestAdminDatasourceMongoid expect(JSON.parse(json_schema)['fields'].keys).to include('title', 'body', 'comments') end + # TODO: works but waiting crud is fully implemented # describe '#list' do # it 'returns a serialized list of records' do # allow(Utils::Query).to receive_message_chain(:new, :get).and_return([Post.new(title: 'Test', body: 'Body')]) # allow(Utils::MongoidSerializer).to receive(:new).and_return(double(to_hash: { title: 'Test', body: 'Body' })) # - # result = collection.list(nil, {}, nil) + # result = collection_post.list(nil, {}, nil) # # expect(result).to eq([{ title: 'Test', body: 'Body' }]) # end From 45831e7660597b1ca314d05f2d328551f2ce206b Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 23 Jan 2025 17:43:29 +0100 Subject: [PATCH 025/104] chore: add tests on parser column --- .../parser/column_spec.rb | 109 ++++++++++++++++++ .../spec/spec_helper.rb | 1 + 2 files changed, 110 insertions(+) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/column_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/column_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/column_spec.rb new file mode 100644 index 000000000..04525b531 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/column_spec.rb @@ -0,0 +1,109 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + module Parser + # include ForestAdminDatasourceToolkit + # include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + describe Column do + let(:dummy_class) { Class.new { extend Column } } + # let(:dummy_class) { Class.new { extend ForestAdminDatasourceMongoid::Parser::Column } } + + let(:columns) do + { + 'array_field' => Mongoid::Fields::Standard.new(:array_field, type: Array), + 'binary_field' => Mongoid::Fields::Standard.new(:binary_field, type: BSON::Binary), + 'decimal_field' => Mongoid::Fields::Standard.new(:decimal_field, type: BigDecimal), + 'boolean_field' => Mongoid::Fields::Standard.new(:boolean_field, type: Mongoid::Boolean), + 'date_field' => Mongoid::Fields::Standard.new(:date_field, type: Date), + 'datetime_field' => Mongoid::Fields::Standard.new(:datetime_field, type: DateTime), + 'float_field' => Mongoid::Fields::Standard.new(:float_field, type: Float), + 'json_field' => Mongoid::Fields::Standard.new(:json_field, type: Hash), + 'integer_field' => Mongoid::Fields::Standard.new(:integer_field, type: Integer), + 'object_field' => Mongoid::Fields::Standard.new(:object_field, type: Object), + 'bson_object_id_field' => Mongoid::Fields::Standard.new(:bson_object_id_field, type: BSON::ObjectId), + 'range_field' => Mongoid::Fields::Standard.new(:range_field, type: Range), + 'regexp_field' => Mongoid::Fields::Standard.new(:regexp_field, type: Regexp), + 'set_field' => Mongoid::Fields::Standard.new(:set_field, type: Set), + 'string_field' => Mongoid::Fields::Standard.new(:string_field, type: String), + 'stringified_symbol_field' => Mongoid::Fields::Standard.new(:stringified_symbol_field, type: Mongoid::StringifiedSymbol), + 'symbol_field' => Mongoid::Fields::Standard.new(:symbol_field, type: Symbol), + 'time_field' => Mongoid::Fields::Standard.new(:time_field, type: Time), + 'active_support_time_field' => Mongoid::Fields::Standard.new(:active_support_time_field, type: ActiveSupport::TimeWithZone) + } + end + + describe 'get_column_type' do + { + 'array_field' => 'Json', + 'binary_field' => 'Binary', + 'decimal_field' => 'Number', + 'boolean_field' => 'Boolean', + 'date_field' => 'Date', + 'datetime_field' => 'Date', + 'float_field' => 'Number', + 'json_field' => 'Json', + 'integer_field' => 'Number', + 'object_field' => 'Json', + 'bson_object_id_field' => 'String', + 'range_field' => 'Json', + 'regexp_field' => 'String', + 'set_field' => 'Json', + 'string_field' => 'String', + 'stringified_symbol_field' => 'String', + 'symbol_field' => 'String', + 'time_field' => 'Date', + 'active_support_time_field' => 'Date' + }.each do |field_name, expected_type| + it "returns correct column type for #{field_name}" do + column = columns[field_name] + expect(dummy_class.get_column_type(column)).to eq expected_type + end + end + end + + describe 'get_default_value' do + it 'returns the default value for columns with a default option' do + column = Mongoid::Fields::Standard.new(:field_with_default, type: String, default: 'DefaultValue') + expect(dummy_class.get_default_value(column)).to eq 'DefaultValue' + end + + it 'returns nil for columns without a default option' do + column = Mongoid::Fields::Standard.new(:no_default_field, type: String) + expect(dummy_class.get_default_value(column)).to be_nil + end + end + + describe 'get_embedded_fields' do + it 'returns embedded fields for a model' do + model = Class.new do + include Mongoid::Document + embeds_many :embedded_items + end + + result = dummy_class.get_embedded_fields(model) + expect(result.keys).to include('embedded_items') + end + + it 'returns an empty hash when no embedded fields are present' do + model = Class.new do + include Mongoid::Document + field :simple_field, type: String + end + + result = dummy_class.get_embedded_fields(model) + expect(result).to eq({}) + end + + # { + # 'Boolean' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL], + # 'Date' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN, Operators::GREATER_THAN, Operators::LESS_THAN], + # 'String' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN, Operators::MATCH, Operators::NOT_CONTAINS, Operators::NOT_I_CONTAINS] + # }.each do |type, expected_operators| + # it "returns correct operators for type #{type}" do + # expect(dummy_class.operators_for_column_type(type)).to eq expected_operators + # end + # end + end + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb b/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb index d404afc70..ba64118e0 100644 --- a/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb +++ b/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb @@ -1,6 +1,7 @@ require 'simplecov' require 'simplecov_json_formatter' require 'simplecov-html' +require 'forest_admin_datasource_toolkit' require 'forest_admin_datasource_mongoid' require 'mongoid-rspec' From bbbe0ae6c255e134c3c52438be4e90f5bde4b7fe Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Mon, 27 Jan 2025 17:29:08 +0100 Subject: [PATCH 026/104] test(collection): add tests on crud methodes --- .../collection.rb | 2 +- .../utils/mongoid_serializer.rb | 2 +- .../collection_spec.rb | 104 ++++++++++++++++-- 3 files changed, 95 insertions(+), 13 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index f4dd6493c..2de372552 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -30,7 +30,7 @@ def aggregate(_caller, filter, aggregation, limit = nil) end def create(_caller, data) - Utils::MongoidSerializer.new(@model.create(data)).to_hash(ProjectionFactory.all(self)) + Utils::MongoidSerializer.new(model.create(data)).to_hash(ProjectionFactory.all(self)) end def update(_caller, filter, data) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb index f915e4750..dc4fb9206 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb @@ -10,7 +10,7 @@ def hash_object(object, projection = nil, with_associations: true) return if object.nil? - object.attributes.each do |key, value| + object.attributes.select { |key, _value| projection.columns.include? key }.each do |key, value| hash[key] = value end diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb index 9e4c6904a..63730cf65 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' module ForestAdminDatasourceMongoid + include ForestAdminDatasourceToolkit::Components::Query + describe Collection do let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new } let(:collection_post) { described_class.new(datasource, Post) } @@ -63,16 +65,96 @@ module ForestAdminDatasourceMongoid expect(JSON.parse(json_schema)['fields'].keys).to include('title', 'body', 'comments') end - # TODO: works but waiting crud is fully implemented - # describe '#list' do - # it 'returns a serialized list of records' do - # allow(Utils::Query).to receive_message_chain(:new, :get).and_return([Post.new(title: 'Test', body: 'Body')]) - # allow(Utils::MongoidSerializer).to receive(:new).and_return(double(to_hash: { title: 'Test', body: 'Body' })) - # - # result = collection_post.list(nil, {}, nil) - # - # expect(result).to eq([{ title: 'Test', body: 'Body' }]) - # end - # end + describe '#list' do + it 'returns a serialized list of records' do + post = Post.new(title: 'Test', body: 'Body') + query_result = [post] + query = instance_double(Utils::Query, get: query_result) + allow(Utils::Query).to receive_messages(new: query) + + result = collection_post.list(nil, Filter.new, Projection.new(%w[_id title body])) + + expect(result).to eq([{ '_id' => post._id, 'title' => post.title, 'body' => post.body }]) + end + + it 'returns a serialized list of records with many to one relation' do + post = Post.new(title: 'Test', body: 'Body') + comment = Comment.new(name: 'foo', message: 'lorem ipsum', post: post) + query_result = [comment] + query = instance_double(Utils::Query, get: query_result) + allow(Utils::Query).to receive_messages(new: query) + + result = collection_comment.list(nil, Filter.new, Projection.new(%w[_id name message post:title])) + + expect(result).to eq( + [ + { + '_id' => comment._id, + 'message' => comment.message, + 'name' => comment.name, + 'post' => { 'title' => post.title } + } + ] + ) + end + end + + describe '#aggregate' do + it 'call QueryAggregate and return an aggregate result' do + Post.new(title: 'Test', body: 'Body') + query = instance_double(Utils::QueryAggregate, get: [{ 'value' => 1 }]) + allow(Utils::QueryAggregate).to receive_messages(new: query) + + filter = Filter.new + aggregation = Aggregation.new(operation: 'Count') + result = collection_post.aggregate(nil, filter, aggregation, 10) + + expect(Utils::QueryAggregate).to( + have_received(:new) do |collection, aggregation_request, filter_request, limit| + expect(collection).to eq(collection_post) + expect(filter).to eq(filter_request) + expect(aggregation).to eq(aggregation_request) + expect(limit).to eq(10) + end + ) + expect(result).to eq([{ 'value' => 1 }]) + end + end + + describe '#create' do + it 'call create method of model and return object with full projection' do + post = Post.new(title: 'Test', body: 'Body') + model = class_double(Post, create: post) + allow(collection_post).to receive(:model).and_return(model) + + result = collection_post.create(nil, { title: 'Test', body: 'Body' }) + + expect(result).to eq({ '_id' => post._id, 'title' => post.title, 'body' => post.body, 'author' => nil }) + end + end + + describe '#update' do + it 'call update method of object' do + model = instance_double(Post, update: true) + query = instance_double(Utils::Query, build: [model]) + allow(Utils::Query).to receive_messages(new: query) + + collection_post.update(nil, Filter.new, { title: 'new title' }) + expect(model).to have_received(:update) do |data| + expect(data).to eq({ title: 'new title' }) + end + end + end + + describe '#delete' do + it 'call destroy method of object' do + model = instance_double(Post, destroy: true) + query = instance_double(Utils::Query, build: [model]) + allow(Utils::Query).to receive_messages(new: query) + + collection_post.delete(nil, Filter.new) + expect(model).to have_received(:destroy) + end + end end end From a48dc185a482d8d4ca66e8e91fbd0ee5edc5a648 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Jan 2025 11:53:49 +0100 Subject: [PATCH 027/104] fix: validations --- .../forest_admin_datasource_mongoid/parser/validation.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/validation.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/validation.rb index a87735b5b..e96804cde 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/validation.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/validation.rb @@ -22,7 +22,7 @@ def get_validations(column) when ActiveModel::Validations::NumericalityValidator.to_s validations = parse_numericality_validator(validator, validations) when Mongoid::Validatable::LengthValidator.to_s - validations = parse_length_validator(validator, validations) + validations = parse_length_validator(validator, validations, column) when Mongoid::Validatable::FormatValidator.to_s validations = parse_format_validator(validator, validations) end @@ -41,9 +41,11 @@ def parse_numericality_validator(validator, parsed_validations) parsed_validations << { operator: Operators::LESS_THAN, value: value } end end + + parsed_validations end - def parse_length_validator(validator, parsed_validations) + def parse_length_validator(validator, parsed_validations, column) return unless get_column_type(column) == 'String' validator.options.each do |option, value| @@ -57,6 +59,8 @@ def parse_length_validator(validator, parsed_validations) parsed_validations << { operator: Operators::SHORTER_THAN, value: value } end end + + parsed_validations end def parse_format_validator(validator, parsed_validations) From d38a920a41caa777f5d928b9b822096f91fb00d7 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Jan 2025 11:55:14 +0100 Subject: [PATCH 028/104] chore: add tests on validation & column --- .../parser/column_spec.rb | 28 +-- .../parser/validation_spec.rb | 199 ++++++++++++++++++ 2 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/validation_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/column_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/column_spec.rb index 04525b531..27d49def8 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/column_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/column_spec.rb @@ -2,11 +2,9 @@ module ForestAdminDatasourceMongoid module Parser - # include ForestAdminDatasourceToolkit - # include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree describe Column do let(:dummy_class) { Class.new { extend Column } } - # let(:dummy_class) { Class.new { extend ForestAdminDatasourceMongoid::Parser::Column } } let(:columns) do { @@ -94,15 +92,21 @@ module Parser expect(result).to eq({}) end - # { - # 'Boolean' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL], - # 'Date' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN, Operators::GREATER_THAN, Operators::LESS_THAN], - # 'String' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN, Operators::MATCH, Operators::NOT_CONTAINS, Operators::NOT_I_CONTAINS] - # }.each do |type, expected_operators| - # it "returns correct operators for type #{type}" do - # expect(dummy_class.operators_for_column_type(type)).to eq expected_operators - # end - # end + { + 'Boolean' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL], + 'Binary' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL], + 'Json' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL], + 'Array' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL], + 'Date' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN, Operators::GREATER_THAN, Operators::LESS_THAN], + 'Dateonly' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN, Operators::GREATER_THAN, Operators::LESS_THAN], + 'Number' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN, Operators::GREATER_THAN, Operators::LESS_THAN], + 'String' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN, Operators::MATCH, Operators::NOT_CONTAINS, Operators::NOT_I_CONTAINS], + 'Enum' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN] + }.each do |type, expected_operators| + it "returns correct operators for type #{type}" do + expect(dummy_class.operators_for_column_type(type)).to eq expected_operators + end + end end end end diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/validation_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/validation_spec.rb new file mode 100644 index 000000000..20d9893aa --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/validation_spec.rb @@ -0,0 +1,199 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + module Parser + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + describe Validation do + let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new } + let(:collection) { ForestAdminDatasourceMongoid::Collection.new(datasource, model) } + + context 'when the model has a before_validation callback' do + let(:model) do + model_class = Class.new do + include Mongoid::Document + before_validation :some_before_validation_callback + validates :email, presence: true + + def some_before_validation_callback + # Simulate callback + end + end + Object.const_set(:PostDocument1, model_class) + model_class + end + + it 'returns empty validations' do + result = collection.get_validations('email') + expect(result).to eq([]) + end + end + + context 'when the model does not have a before_validation callback' do + let(:model) do + model_class = Class.new do + include Mongoid::Document + field :email, type: String + validates :email, presence: true + end + + Object.const_set(:PostDocument2, model_class) + model_class + end + + it 'returns validations' do + email_column = model.fields['email'] + result = collection.get_validations(email_column) + + expect(result).to eq([{ operator: Operators::PRESENT }]) + end + end + + context 'when the model has a numericality validator' do + context 'with a greater_than validation' do + let(:model) do + model_class = Class.new do + include Mongoid::Document + field :age, type: Integer + validates :age, numericality: { greater_than: 20 } + end + + Object.const_set(:PostDocument3, model_class) + model_class + end + + it 'returns a greater_than validation' do + age_column = model.fields['age'] + result = collection.get_validations(age_column) + + expect(result).to include({ operator: Operators::GREATER_THAN, value: 20 }) + end + end + + context 'with a less_than validation' do + let(:model) do + model_class = Class.new do + include Mongoid::Document + field :age, type: Integer + validates :age, numericality: { less_than: 50 } + end + + Object.const_set(:PostDocument4, model_class) + model_class + end + + it 'returns a less_than validation' do + age_column = model.fields['age'] + result = collection.get_validations(age_column) + + expect(result).to include({ operator: Operators::LESS_THAN, value: 50 }) + end + end + end + + context 'when the model has a length validator' do + context 'with a minimum length validation' do + let(:model) do + model_class = Class.new do + include Mongoid::Document + field :title, type: String + validates :title, length: { minimum: 5 } + end + + Object.const_set(:PostDocument5, model_class) + model_class + end + + it 'returns a minimum length validation' do + title_column = model.fields['title'] + result = collection.get_validations(title_column) + + expect(result).to include({ operator: Operators::LONGER_THAN, value: 5 }) + end + end + + context 'with a maximum length validation' do + let(:model) do + model_class = Class.new do + include Mongoid::Document + field :title, type: String + validates :title, length: { maximum: 10 } + end + + Object.const_set(:PostDocument6, model_class) + model_class + end + + it 'returns a maximum length validation' do + title_column = model.fields['title'] + result = collection.get_validations(title_column) + + expect(result).to include({ operator: Operators::SHORTER_THAN, value: 10 }) + end + end + + context 'with an exact length validator' do + let(:model) do + model_class = Class.new do + include Mongoid::Document + field :title, type: String + validates :title, length: { is: 8 } + end + + Object.const_set(:PostDocument7, model_class) + model_class + end + + it 'returns an exact length validation' do + title_column = model.fields['title'] + result = collection.get_validations(title_column) + + expect(result).to include({ operator: Operators::LONGER_THAN, value: 8 }) + expect(result).to include({ operator: Operators::SHORTER_THAN, value: 8 }) + end + end + end + + context 'when the model has a format validator' do + context 'with a valid email format' do + let(:model) do + model_class = Class.new do + include Mongoid::Document + field :email, type: String + validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } + end + + Object.const_set(:PostDocument8, model_class) + model_class + end + + it 'returns a format validation for email' do + email_column = model.fields['email'] + result = collection.get_validations(email_column) + + expect(result).to include({ operator: Operators::CONTAINS, value: "/^[a-zA-Z0-9.!\\\#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/" }) + end + end + + context 'with a custom format validation' do + let(:model) do + model_class = Class.new do + include Mongoid::Document + field :username, type: String + validates :username, format: { with: /\A[a-zA-Z0-9_]+\z/, message: 'only allows letters, numbers, and underscores' } + end + + Object.const_set(:PostDocument9, model_class) + model_class + end + + it 'returns a custom format validation' do + username_column = model.fields['username'] + result = collection.get_validations(username_column) + + expect(result).to include({ operator: Operators::CONTAINS, value: '/^[a-zA-Z0-9_]+$/' }) + end + end + end + end + end +end From 45d1903d1e35f6df01f1c5e6dbdf76df8e7ae01c Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Tue, 28 Jan 2025 14:25:50 +0100 Subject: [PATCH 029/104] feat: build query with OneToOne and ManyToOne relations --- .../utils/query.rb | 91 ++++++++++--------- 1 file changed, 49 insertions(+), 42 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb index d75c0d8ad..a190eeb64 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb @@ -11,6 +11,7 @@ def initialize(collection, projection, filter) @projection = projection @filter = filter @select = [] + @virtual_joins = {} end def build @@ -21,7 +22,6 @@ def build def get build - return @query.all if @filter.page.nil? @query.skip(@filter.page.offset).limit(@filter.page.limit) @@ -29,11 +29,24 @@ def get def apply_filter @query = apply_condition_tree(@filter.condition_tree) unless @filter.condition_tree.nil? + @query = apply_referenced_relations @query = apply_sort(@filter.sort) unless @filter.sort.nil? @query end + def apply_referenced_relations + @virtual_joins.each do |relation_name, match| + relation = @collection.schema[:fields][relation_name] + if relation.type == 'OneToOne' + @query = @query.in(relation.origin_key_target => match.distinct(relation.origin_key)) + elsif relation.type == 'ManyToOne' + @query = @query.in(relation.foreign_key => match.distinct(relation.foreign_key_target)) + end + end + @query + end + def apply_sort(sort) sort.each do |sort_clause| field = format_field(sort_clause[:field]) @@ -56,44 +69,62 @@ def apply_condition_tree(condition_tree, aggregator = nil) @query = apply_condition_tree(condition, aggregator) @query = @query.send(aggregator, @query) end - - @query + elsif referenced_relation?(condition_tree.field) + relation_name, field_name = condition_tree.field.split(':') + relation = @collection.model.relations[relation_name] + @virtual_joins[relation_name] = relation.klass.unscoped unless @virtual_joins[relation_name] + @virtual_joins[relation_name] = compute_main_operator( + @virtual_joins[relation_name], + condition_tree.override(field: field_name), + aggregator || :and + ) else - @query = compute_main_operator(condition_tree, aggregator || :and) + @query = compute_main_operator(@query, condition_tree, aggregator || :and) end + + @query end - def compute_main_operator(condition_tree, aggregator) + def compute_main_operator(query, condition_tree, aggregator) field = format_field(condition_tree.field) value = condition_tree.value aggregator = aggregator.to_sym case condition_tree.operator when Operators::PRESENT - @query = @query.send(aggregator, @collection.model.not.where(field => nil)) + query = query.send(aggregator, @collection.model.not.where(field => nil)) when Operators::EQUAL - @query = @query.send(aggregator, @collection.model.where(field => value)) + query = query.send(aggregator, @collection.model.where(field => value)) when Operators::IN - @query = @query.send(aggregator, @collection.model.in(field => value)) + query = query.send(aggregator, @collection.model.in(field => value)) when Operators::NOT_EQUAL - @query = @query.send(aggregator, @collection.model.not.where(field => value)) + query = query.send(aggregator, @collection.model.not.where(field => value)) when Operators::NOT_IN - @query = @query.send(aggregator, @collection.model.not.in(field => value)) + query = query.send(aggregator, @collection.model.not.in(field => value)) when Operators::GREATER_THAN - @query = @query.send(aggregator, @collection.model.where(field => { '$gt' => value })) + query = query.send(aggregator, @collection.model.where(field => { '$gt' => value })) when Operators::LESS_THAN - @query = @query.send(aggregator, @collection.model.where(field => { '$lt' => value })) + query = query.send(aggregator, @collection.model.where(field => { '$lt' => value })) when Operators::NOT_CONTAINS - @query = @query.send(aggregator, @collection.model.not.where(field => Regexp.new("^.*#{value}.*$"))) + query = query.send(aggregator, @collection.model.not.where(field => Regexp.new("^.*#{value}.*$"))) when Operators::NOT_I_CONTAINS - @query = @query.send(aggregator, @collection.model.not.where(field => Regexp.new("^.*#{value}.*$", 'i'))) + query = query.send(aggregator, @collection.model.not.where(field => Regexp.new("^.*#{value}.*$", 'i'))) when Operators::MATCH - @query = @query.send(aggregator, @collection.model.where(field => { '$regex' => value })) + query = query.send(aggregator, @collection.model.where(field => { '$regex' => value })) when Operators::INCLUDES_ALL - @query = @query.send(aggregator, @collection.model.where(field => { '$all' => value })) + query = query.send(aggregator, @collection.model.where(field => { '$all' => value })) end - @query + query + end + + def referenced_relation?(path) + return false unless path.include?(':') + + relation_name, = path.split(':') + + @collection.model.relations.key?(relation_name) && + !@collection.model.relations[relation_name].embedded? end def build_select @@ -118,35 +149,11 @@ def apply_select @query end - def add_join_relation(relation_name) - @query = @query.includes(relation_name.to_sym) - - @query - end - def format_field(field) - # TODO - if field.include?(':') - relation_name, field = field.split(':') - relation = @collection.schema[:fields][relation_name] - table_name = @collection.datasource.get_collection(relation.foreign_collection).model.table_name - add_join_relation(relation_name) - - return "#{table_name}.#{field}" - end - - field + field.tr(':', '.') end def format_relation_projection(projection) - # result = {} - # projection&.relations&.each do |relation, projection_relation| - # formatted_relations = format_relation_projection(projection_relation) - # - # result[relation.to_sym] = formatted_relations - # end - # result - result = [] projection&.relations&.each_value do |projection_relation| formatted_relations = format_relation_projection(projection_relation) From 32981c562afbfd2cfc21f3fe6485175459a6a838 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Jan 2025 15:12:35 +0100 Subject: [PATCH 030/104] chore: add tests for relation parser --- .../parser/relation_spec.rb | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/relation_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/relation_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/relation_spec.rb new file mode 100644 index 000000000..d3a6089b9 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/relation_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + module Parser + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + describe Validation do + let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new } + let(:collection) { ForestAdminDatasourceMongoid::Collection.new(datasource, model_class) } + + context 'when models with polymorphic relations exist' do + let(:model_class) { User } + + it 'returns the correct mapping of polymorphic types and primary keys' do + result = collection.get_polymorphic_types('item') + + expect(result).to eq({ 'Departure' => '_id', 'Team' => '_id' }) + end + end + + context 'when models does not have polymorphic relation' do + let(:model_class) { Post } + + it 'returns an empty hash' do + result = collection.get_polymorphic_types('author') + + expect(result).to eq({}) + end + end + end + end +end From 3281adefcbf7ddce5121cc79aa43433e660a6434 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Tue, 28 Jan 2025 16:48:59 +0100 Subject: [PATCH 031/104] test(serializer): add tests on mongoid serializer --- .../utils/mongoid_serializer.rb | 4 +- .../utils/mongoid_serializer_spec.rb | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb index dc4fb9206..fd85656e6 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb @@ -1,11 +1,11 @@ module ForestAdminDatasourceMongoid module Utils MongoidSerializer = Struct.new(:object) do - def to_hash(projection = nil) + def to_hash(projection) hash_object(object, projection) end - def hash_object(object, projection = nil, with_associations: true) + def hash_object(object, projection, with_associations: true) hash = {} return if object.nil? diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer_spec.rb new file mode 100644 index 000000000..b9dd3c3d7 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + module Utils + include ForestAdminDatasourceToolkit::Components::Query + describe MongoidSerializer do + describe '#to_hash' do + describe 'without relations' do + let(:post) { Post.new(title: 'foo', body: 'fake content') } + + it 'return a mongoid object serialized with full projection' do + expect(described_class.new(post).to_hash(Projection.new(%w[title body]))).to eq( + { 'title' => 'foo', 'body' => 'fake content' } + ) + end + + it 'return a mongoid object serialized with partial projection' do + expect(described_class.new(post).to_hash(Projection.new(%w[title]))).to eq( + { 'title' => 'foo' } + ) + end + end + + describe 'with relations' do + let(:post) { Post.create!(title: 'foo', body: 'fake content') } + + it 'return a mongoid object serialized with full projection' do + expect(described_class.new(post).to_hash(Projection.new(%w[title body]))).to eq( + { 'title' => 'foo', 'body' => 'fake content' } + ) + end + + # it 'return a mongoid object serialized with partial projection' do + # post = Post.new(title: 'foo', body: 'fake content') + # expect(described_class.new(post).to_hash(Projection.new(%w[title]))).to eq( + # { 'title' => 'foo' } + # ) + # end + end + end + end + end +end From 5959d12ac2cf1d4650ff8f713d56561d8c4786ed Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Tue, 28 Jan 2025 16:53:54 +0100 Subject: [PATCH 032/104] fix: lint --- .../forest_admin_datasource_mongoid/utils/mongoid_serializer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb index fd85656e6..4ff56b219 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer.rb @@ -10,7 +10,7 @@ def hash_object(object, projection, with_associations: true) return if object.nil? - object.attributes.select { |key, _value| projection.columns.include? key }.each do |key, value| + object.attributes.slice(*projection.columns).each do |key, value| hash[key] = value end From d4f746bbc417148b3c850b2761ffcdee76a5646c Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Tue, 28 Jan 2025 17:04:48 +0100 Subject: [PATCH 033/104] fix: tests on transforms pattern --- .../query/condition_tree/transforms/pattern_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/pattern_spec.rb b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/pattern_spec.rb index 7544bc10e..cf78fdbc7 100644 --- a/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/pattern_spec.rb +++ b/packages/forest_admin_datasource_toolkit/spec/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/pattern_spec.rb @@ -53,13 +53,13 @@ module Transforms it 'Like should be rewritten' do expect(@pattern[Operators::LIKE][0][:replacer].call(ConditionTreeLeaf.new('column', Operators::EQUAL, 'something')).to_h) - .to eq(ConditionTreeLeaf.new('column', Operators::MATCH, '/^something$/').to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::MATCH, /^something$/).to_h) end it 'ILike should be rewritten' do expect(@pattern[Operators::I_LIKE][0][:replacer].call(ConditionTreeLeaf.new('column', Operators::EQUAL, 'something')).to_h) - .to eq(ConditionTreeLeaf.new('column', Operators::MATCH, '/^something$/i').to_h) + .to eq(ConditionTreeLeaf.new('column', Operators::MATCH, /^something$/i).to_h) end end end From b6fc69ca7faad87ce6ede37463ec70ad71e2a51d Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Tue, 28 Jan 2025 17:09:17 +0100 Subject: [PATCH 034/104] chore: update ci config --- .github/workflows/build.yml | 3 +++ .../forest_admin_datasource_mongoid/spec/spec_helper.rb | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 42ffbf887..f402a59fa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,6 +17,7 @@ jobs: packages: - forest_admin_agent - forest_admin_datasource_active_record + - forest_admin_datasource_mongoid - forest_admin_datasource_customizer - forest_admin_datasource_toolkit - forest_admin_test_toolkit @@ -52,6 +53,7 @@ jobs: packages: - forest_admin_agent - forest_admin_datasource_active_record + - forest_admin_datasource_mongoid - forest_admin_datasource_customizer - forest_admin_datasource_toolkit steps: @@ -107,6 +109,7 @@ jobs: coverageLocations: | ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_agent/coverage.json:simplecov ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_active_record/coverage.json:simplecov + ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mongoid/coverage.json:simplecov ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_customizer/coverage.json:simplecov ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_toolkit/coverage.json:simplecov debug: true diff --git a/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb b/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb index ba64118e0..ec635bdb0 100644 --- a/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb +++ b/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb @@ -10,6 +10,12 @@ add_filter 'spec' end +SimpleCov.coverage_dir 'coverage' +SimpleCov.at_exit do + result = SimpleCov.result + result.format! +end + ENV['RAILS_ENV'] ||= 'test' require File.expand_path('dummy/config/environment', __dir__) From 3e50e66e3cf45dfcb0e178798f8c692e44afcabd Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Tue, 28 Jan 2025 17:19:20 +0100 Subject: [PATCH 035/104] fix: tests on condition tree parser --- .../forest_admin_agent/utils/condition_tree_parser_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/condition_tree_parser_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/condition_tree_parser_spec.rb index bdfb372e8..a7a9de395 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/condition_tree_parser_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/utils/condition_tree_parser_spec.rb @@ -70,21 +70,21 @@ module Utils filters = { field: 'label', operator: Operators::IN, value: ' id1,id2 , id3' } expect(condition_tree_parser.from_plain_object(collection_category, filters)) - .to have_attributes(field: filters[:field], operator: filters[:operator], value: filters[:value]) + .to have_attributes(field: filters[:field], operator: filters[:operator], value: %w[id1 id2 id3]) end it 'works with "IN" on a boolean' do filters = { field: 'active', operator: Operators::IN, value: 'true,0,false,yes,no' } expect(condition_tree_parser.from_plain_object(collection_category, filters)) - .to have_attributes(field: filters[:field], operator: filters[:operator], value: filters[:value]) + .to have_attributes(field: filters[:field], operator: filters[:operator], value: [true, false, false, true, false]) end it 'works with "IN" on a number' do filters = { field: 'id', operator: Operators::IN, value: '1,2,3' } expect(condition_tree_parser.from_plain_object(collection_category, filters)) - .to have_attributes(field: filters[:field], operator: filters[:operator], value: filters[:value]) + .to have_attributes(field: filters[:field], operator: filters[:operator], value: [1, 2, 3]) end end end From 6b7c18687be86d5d18682a449afa2db07baedf8b Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Tue, 28 Jan 2025 17:34:05 +0100 Subject: [PATCH 036/104] fix: condition tree transform pattern --- .../components/query/condition_tree/transforms/pattern.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/pattern.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/pattern.rb index 1adad2d30..c3ff9774f 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/pattern.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/transforms/pattern.rb @@ -23,7 +23,7 @@ def self.match(case_sensitive) regex.gsub!('%', '.*') regex.tr!('_', '.') - leaf.override(operator: Operators::MATCH, value: Regexp.new("^#{regex}$", case_sensitive ? '' : 'i')) + leaf.override(operator: Operators::MATCH, value: Regexp.new("^#{regex}$", case_sensitive ? nil : 'i')) } } end From 7c2b70c0f3d5c1121dd9afc68695fb7d19081d7a Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Tue, 28 Jan 2025 17:41:27 +0100 Subject: [PATCH 037/104] chore: update rails version in gemfile-test --- packages/forest_admin_datasource_mongoid/Gemfile-test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/forest_admin_datasource_mongoid/Gemfile-test b/packages/forest_admin_datasource_mongoid/Gemfile-test index 8de2d4f8a..dcf8aacd6 100644 --- a/packages/forest_admin_datasource_mongoid/Gemfile-test +++ b/packages/forest_admin_datasource_mongoid/Gemfile-test @@ -10,7 +10,7 @@ gem 'mongoid', '~> 9.0' group :development, :test do gem 'forest_admin_datasource_toolkit', path: '../forest_admin_datasource_toolkit' gem 'mongoid-rspec', '~> 4.0' - gem 'rails' + gem 'rails', '~> 8.0' gem 'rspec-rails', '~> 3.0' gem 'simplecov', '~> 0.22', require: false gem 'simplecov-html', '~> 0.12.3' From 591e4ddf841f5ee123f73b6b4f13c431754e4c9b Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Tue, 28 Jan 2025 17:46:07 +0100 Subject: [PATCH 038/104] chore: update rails version --- packages/forest_admin_datasource_mongoid/Gemfile-test | 2 +- .../spec/dummy/config/application.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/Gemfile-test b/packages/forest_admin_datasource_mongoid/Gemfile-test index dcf8aacd6..8de2d4f8a 100644 --- a/packages/forest_admin_datasource_mongoid/Gemfile-test +++ b/packages/forest_admin_datasource_mongoid/Gemfile-test @@ -10,7 +10,7 @@ gem 'mongoid', '~> 9.0' group :development, :test do gem 'forest_admin_datasource_toolkit', path: '../forest_admin_datasource_toolkit' gem 'mongoid-rspec', '~> 4.0' - gem 'rails', '~> 8.0' + gem 'rails' gem 'rspec-rails', '~> 3.0' gem 'simplecov', '~> 0.22', require: false gem 'simplecov-html', '~> 0.12.3' diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/application.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/config/application.rb index 895672355..b53654389 100644 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/config/application.rb +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/application.rb @@ -23,7 +23,7 @@ module Dummy class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 8.0 + config.load_defaults 7.0 # Please, add to the `ignore` list any other `lib` subdirectories that do # not contain `.rb` files, or that should not be reloaded or eager loaded. From bab1304e9910ee5508ae5fab2fb5cee405c12e65 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 29 Jan 2025 10:17:52 +0100 Subject: [PATCH 039/104] fix: tests --- .../lib/forest_admin_datasource_mongoid/datasource.rb | 2 +- .../lib/forest_admin_datasource_mongoid/parser/column_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb index 7c9542f3f..e30345320 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb @@ -14,7 +14,7 @@ def initialize def generate models = ObjectSpace.each_object(Class).select do |klass| - klass < Mongoid::Document && !klass.name.start_with?('Mongoid::') && !embedded_in_relation?(klass) + klass < Mongoid::Document && klass.name && !klass.name.start_with?('Mongoid::') && !embedded_in_relation?(klass) end models.each do |model| diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/column_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/column_spec.rb index 27d49def8..2ac13a0d8 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/column_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/column_spec.rb @@ -96,7 +96,7 @@ module Parser 'Boolean' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL], 'Binary' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL], 'Json' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL], - 'Array' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL], + [] => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL], 'Date' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN, Operators::GREATER_THAN, Operators::LESS_THAN], 'Dateonly' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN, Operators::GREATER_THAN, Operators::LESS_THAN], 'Number' => [Operators::PRESENT, Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN, Operators::GREATER_THAN, Operators::LESS_THAN], From a36413adbbc72f9a9cdeaaa549244ffa75a04e2a Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 29 Jan 2025 10:23:42 +0100 Subject: [PATCH 040/104] chore: dummy controller --- .../spec/dummy/app/controllers/application_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/controllers/application_controller.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/controllers/application_controller.rb index 0d95db22b..5caea1d44 100644 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/app/controllers/application_controller.rb +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/controllers/application_controller.rb @@ -1,4 +1,4 @@ class ApplicationController < ActionController::Base # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. - allow_browser versions: :modern + # allow_browser versions: :modern end From 87a14da5d2c7fc2d5f57128e870a8407fa1f5674 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 29 Jan 2025 10:57:48 +0100 Subject: [PATCH 041/104] chore: ci add services mongodb --- .github/workflows/build.yml | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f402a59fa..661780daf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,11 +17,11 @@ jobs: packages: - forest_admin_agent - forest_admin_datasource_active_record - - forest_admin_datasource_mongoid - forest_admin_datasource_customizer - forest_admin_datasource_toolkit - forest_admin_test_toolkit - forest_admin_rails + - forest_admin_datasource_mongoid steps: - name: Checkout @@ -53,9 +53,15 @@ jobs: packages: - forest_admin_agent - forest_admin_datasource_active_record - - forest_admin_datasource_mongoid - forest_admin_datasource_customizer - forest_admin_datasource_toolkit + - forest_admin_datasource_mongoid + services: + mongodb: + if: ${{ matrix.packages == 'forest_admin_datasource_mongoid' }} + image: mongo:latest + ports: + - 27017:27017 steps: - name: Checkout uses: actions/checkout@v4 @@ -70,6 +76,16 @@ jobs: gem install bundler bundle install + - name: Wait for MongoDB + if: ${{ matrix.packages == 'forest_admin_datasource_mongoid' }} + run: | + for i in {1..30}; do + nc -z localhost 27017 && echo "MongoDB is up" && exit 0 + echo "Waiting for MongoDB..." + sleep 1 + done + exit 1 + - name: Test run: | cd packages/${{ matrix.packages }} && \ @@ -109,9 +125,9 @@ jobs: coverageLocations: | ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_agent/coverage.json:simplecov ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_active_record/coverage.json:simplecov - ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mongoid/coverage.json:simplecov ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_customizer/coverage.json:simplecov ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_toolkit/coverage.json:simplecov + ${{ github.workspace }}/reports/${{ matrix.ruby-version }}-forest_admin_datasource_mongoid/coverage.json:simplecov debug: true deploy: From 8c6c186364a097b2858de38e12a6616ef73ef089 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 29 Jan 2025 11:00:20 +0100 Subject: [PATCH 042/104] fix: ci --- .github/workflows/build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 661780daf..ac50a4d6e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,7 +58,6 @@ jobs: - forest_admin_datasource_mongoid services: mongodb: - if: ${{ matrix.packages == 'forest_admin_datasource_mongoid' }} image: mongo:latest ports: - 27017:27017 From 010b559a67a6a7a7a62d1c2615b66e8b6a05d8a7 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 30 Jan 2025 09:11:25 +0100 Subject: [PATCH 043/104] fix: format_attributes for jsonapi --- .../forest_admin_agent/routes/abstract_authenticated_route.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb index d612e08f3..0035c5131 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_authenticated_route.rb @@ -16,7 +16,7 @@ def format_attributes(args) args[:params][:data][:relationships]&.map do |field, value| schema = @collection.schema[:fields][field] - record[schema.foreign_key] = value['data'][schema.foreign_key_target] if schema.type == 'ManyToOne' + record[schema.foreign_key] = value['data']['id'] if schema.type == 'ManyToOne' end record || {} From c2276e309061c4ccc1815fc1ec08344900781c19 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Thu, 30 Jan 2025 17:04:25 +0100 Subject: [PATCH 044/104] test: add test on query builder --- .../utils/query.rb | 22 +- .../utils/query_spec.rb | 219 ++++++++++++++++++ 2 files changed, 229 insertions(+), 12 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/query_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb index a190eeb64..5b65dd270 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb @@ -67,7 +67,6 @@ def apply_condition_tree(condition_tree, aggregator = nil) aggregator = condition_tree.aggregator.downcase.to_sym condition_tree.conditions.each do |condition| @query = apply_condition_tree(condition, aggregator) - @query = @query.send(aggregator, @query) end elsif referenced_relation?(condition_tree.field) relation_name, field_name = condition_tree.field.split(':') @@ -128,18 +127,17 @@ def referenced_relation?(path) end def build_select - unless @projection.nil? - @select = @projection.columns - @projection.relations.each_key do |relation| - relation_schema = @collection.schema[:fields][relation] - @select << if %w[OneToOne PolymorphicOneToOne].include?(relation_schema.type) - "#{@collection.model.collection_name}.#{relation_schema.origin_key_target}" - else - "#{@collection.model.collection_name}.#{relation_schema.foreign_key}" - end - end + return if @projection.nil? + + @select = @projection.columns + @projection.relations.each_key do |relation| + relation_schema = @collection.schema[:fields][relation] + @select << if %w[OneToOne PolymorphicOneToOne].include?(relation_schema.type) + "#{@collection.model.collection_name}.#{relation_schema.origin_key_target}" + else + "#{@collection.model.collection_name}.#{relation_schema.foreign_key}" + end end - @query end def apply_select diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/query_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/query_spec.rb new file mode 100644 index 000000000..1af1c0163 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/query_spec.rb @@ -0,0 +1,219 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + module Utils + include ForestAdminDatasourceToolkit::Components::Query + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes + describe Query do + let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new } + let(:collection) { datasource.get_collection('Post') } + + describe 'apply sort' do + it 'add sort clauses to the query' do + sort = Sort.new([{ field: 'title', ascending: true }, { field: 'body', ascending: false }]) + filter = Filter.new(sort: sort) + query_builder = described_class.new(collection, Projection.new, filter) + query_builder.build + + expect(query_builder.query.options).to eq({ sort: { 'body' => -1, 'title' => 1 } }) + end + end + + describe 'apply pagination' do + it 'add pagination clause to the query' do + page = Page.new(offset: 10, limit: 20) + filter = Filter.new(page: page) + query_builder = described_class.new(collection, Projection.new, filter) + query = query_builder.get + + expect(query.options).to eq({ skip: 10, limit: 20 }) + end + + it 'do not add limitation clause to the query if pagination is missing' do + query_builder = described_class.new(collection, Projection.new, Filter.new) + query = query_builder.get + + expect(query.options).to eq({}) + end + end + + describe 'apply projection' do + it 'add projection to the query' do + projection = Projection.new(%w[_id title body]) + query_builder = described_class.new(collection, projection, Filter.new) + query_builder.build + + expect(query_builder.query.options).to eq({ fields: { '_id' => 1, 'title' => 1, 'body' => 1 } }) + end + end + + describe 'apply condition tree' do + context 'with simple condition tree' do + it 'add present condition' do + filter = Filter.new( + condition_tree: ConditionTreeLeaf.new('title', Operators::PRESENT) + ) + query_builder = described_class.new(collection, Projection.new, filter) + query_builder.build + + expect(query_builder.query.selector).to eq({ 'title' => { '$ne' => nil } }) + end + + it 'add equal condition' do + filter = Filter.new( + condition_tree: ConditionTreeLeaf.new('title', Operators::EQUAL, 'foo') + ) + query_builder = described_class.new(collection, Projection.new, filter) + query_builder.build + + expect(query_builder.query.selector).to eq({ 'title' => 'foo' }) + end + + it 'add in condition' do + filter = Filter.new( + condition_tree: ConditionTreeLeaf.new('title', Operators::IN, 'foo') + ) + query_builder = described_class.new(collection, Projection.new, filter) + query_builder.build + + expect(query_builder.query.selector).to eq({ 'title' => { '$in' => ['foo'] } }) + end + + it 'add not_equal condition' do + filter = Filter.new( + condition_tree: ConditionTreeLeaf.new('title', Operators::NOT_EQUAL, 'foo') + ) + query_builder = described_class.new(collection, Projection.new, filter) + query_builder.build + + expect(query_builder.query.selector).to eq({ 'title' => { '$ne' => 'foo' } }) + end + + it 'add not_in condition' do + filter = Filter.new( + condition_tree: ConditionTreeLeaf.new('title', Operators::NOT_IN, 'foo') + ) + query_builder = described_class.new(collection, Projection.new, filter) + query_builder.build + + expect(query_builder.query.selector).to eq({ 'title' => { '$not' => { '$in' => ['foo'] } } }) + end + + it 'add greater_than condition' do + filter = Filter.new( + condition_tree: ConditionTreeLeaf.new('title', Operators::GREATER_THAN, 1) + ) + query_builder = described_class.new(collection, Projection.new, filter) + query_builder.build + + expect(query_builder.query.selector).to eq({ 'title' => { '$gt' => '1' } }) + end + + it 'add less_than condition' do + filter = Filter.new( + condition_tree: ConditionTreeLeaf.new('title', Operators::LESS_THAN, 1) + ) + query_builder = described_class.new(collection, Projection.new, filter) + query_builder.build + + expect(query_builder.query.selector).to eq({ 'title' => { '$lt' => '1' } }) + end + + it 'add not_contains condition' do + filter = Filter.new( + condition_tree: ConditionTreeLeaf.new('title', Operators::NOT_CONTAINS, 'foo') + ) + query_builder = described_class.new(collection, Projection.new, filter) + query_builder.build + + expect(query_builder.query.selector).to eq({ 'title' => { '$not' => /^.*foo.*$/ } }) + end + + it 'add not_i_contains condition' do + filter = Filter.new( + condition_tree: ConditionTreeLeaf.new('title', Operators::NOT_I_CONTAINS, 'foo') + ) + query_builder = described_class.new(collection, Projection.new, filter) + query_builder.build + + expect(query_builder.query.selector).to eq({ 'title' => { '$not' => /^.*foo.*$/i } }) + end + + it 'add match condition' do + filter = Filter.new( + condition_tree: ConditionTreeLeaf.new('title', Operators::MATCH, 'foo') + ) + query_builder = described_class.new(collection, Projection.new, filter) + query_builder.build + + expect(query_builder.query.selector).to eq({ 'title' => { '$regex' => 'foo' } }) + end + end + + context 'with condition tree branch' do + it 'add condition with "and" aggregator' do + filter = Filter.new( + condition_tree: ConditionTreeBranch.new( + 'and', + [ + ConditionTreeLeaf.new('title', Operators::PRESENT), + ConditionTreeLeaf.new('body', Operators::PRESENT) + ] + ) + ) + query_builder = described_class.new(collection, Projection.new, filter) + query_builder.build + + expect(query_builder.query.selector).to eq( + { 'body' => { '$ne' => nil }, 'title' => { '$ne' => nil } } + ) + end + + it 'add condition with "or" aggregator' do + filter = Filter.new( + condition_tree: ConditionTreeBranch.new( + 'or', + [ + ConditionTreeLeaf.new('title', Operators::PRESENT), + ConditionTreeLeaf.new('body', Operators::PRESENT) + ] + ) + ) + query_builder = described_class.new(collection, Projection.new, filter) + query_builder.build + + expect(query_builder.query.selector).to eq( + { '$or' => [{ 'title' => { '$ne' => nil } }, { 'body' => { '$ne' => nil } }] } + ) + end + + it 'add condition with nested condition tree branch' do + filter = Filter.new( + condition_tree: ConditionTreeBranch.new( + 'and', + [ + ConditionTreeBranch.new( + 'or', + [ + ConditionTreeLeaf.new('title', Operators::PRESENT), + ConditionTreeLeaf.new('body', Operators::PRESENT) + ] + ), + ConditionTreeLeaf.new('title', Operators::PRESENT), + ConditionTreeLeaf.new('body', Operators::PRESENT) + ] + ) + ) + query_builder = described_class.new(collection, Projection.new, filter) + query_builder.build + + expect(query_builder.query.selector).to eq( + { '$or' => [{ 'title' => { '$ne' => nil } }, { 'body' => { '$ne' => nil } }], 'body' => { '$ne' => nil }, 'title' => { '$ne' => nil } } + ) + end + end + end + end + end +end From 1da2c73dc1af164bedfe4b4ee9a8259f56e82cd9 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Thu, 30 Jan 2025 17:05:13 +0100 Subject: [PATCH 045/104] test: add test on mongoid serializer --- .../utils/mongoid_serializer_spec.rb | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer_spec.rb index b9dd3c3d7..9cc2d642c 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer_spec.rb @@ -22,20 +22,40 @@ module Utils end describe 'with relations' do - let(:post) { Post.create!(title: 'foo', body: 'fake content') } + let(:post) { Post.first } + let(:author) { Author.first } - it 'return a mongoid object serialized with full projection' do - expect(described_class.new(post).to_hash(Projection.new(%w[title body]))).to eq( - { 'title' => 'foo', 'body' => 'fake content' } + before do + Author.create!( + first_name: 'john', + last_name: 'doe', + post: Post.create!(title: 'foo', body: 'fake content') + ) + end + + it 'return a mongoid object serialized based on projection with ManyToOne relation' do + projection = Projection.new(%w[first_name last_name post:title post:body]) + + expect(described_class.new(author).to_hash(projection)).to eq( + { + 'first_name' => 'john', + 'last_name' => 'doe', + 'post' => { 'title' => 'foo', 'body' => 'fake content' } + } ) end - # it 'return a mongoid object serialized with partial projection' do - # post = Post.new(title: 'foo', body: 'fake content') - # expect(described_class.new(post).to_hash(Projection.new(%w[title]))).to eq( - # { 'title' => 'foo' } - # ) - # end + it 'return a mongoid object serialized based on projection with OneToOne relation' do + projection = Projection.new(%w[title body author:first_name author:lastname]) + + expect(described_class.new(post).to_hash(projection)).to eq( + { + 'title' => 'foo', + 'body' => 'fake content', + 'author' => { 'first_name' => 'john' } + } + ) + end end end end From 34d9ad3acffedec9d4b5a40b6da0dae3cefbf393 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Fri, 31 Jan 2025 14:30:22 +0100 Subject: [PATCH 046/104] test: add test on query aggregate --- .../utils/query_aggregate.rb | 2 +- .../spec/dummy/app/models/post.rb | 1 + .../utils/query_aggregate_spec.rb | 90 +++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/query_aggregate_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query_aggregate.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query_aggregate.rb index 1ce588b3d..5edd11861 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query_aggregate.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query_aggregate.rb @@ -11,7 +11,7 @@ class QueryAggregate < Query 'Week' => '%Y-%m-%d' }.freeze - def initialize(collection, aggregation, filter = nil, limit = nil) + def initialize(collection, aggregation, filter, limit = nil) super(collection, ForestAdminDatasourceToolkit::Components::Query::Projection.new, filter) @aggregation = aggregation @limit = limit diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/post.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/post.rb index 74894da72..5e2ec981b 100644 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/post.rb +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/post.rb @@ -3,6 +3,7 @@ class Post include Mongoid::Timestamps field :title, type: String field :body, type: String + field :rating, type: Integer has_many :comments, dependent: :destroy has_one :author diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/query_aggregate_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/query_aggregate_spec.rb new file mode 100644 index 000000000..3ee534bdf --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/query_aggregate_spec.rb @@ -0,0 +1,90 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + module Utils + include ForestAdminDatasourceToolkit::Components::Query + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes + describe QueryAggregate do + let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new } + let(:collection) { datasource.get_collection('Post') } + + describe 'apply limit' do + it 'add limit clauses to the query' do + aggregation = Aggregation.new(operation: 'Count') + query_aggregate = described_class.new(collection, aggregation, Filter.new, 10) + query_aggregate.get + + expect(query_aggregate.query.options).to eq({ limit: 10 }) + end + end + + describe 'apply aggregate operation' do + it 'add count operation to the query' do + aggregation = Aggregation.new(operation: 'Count') + query_aggregate = described_class.new(collection, aggregation, Filter.new) + query_aggregate.get + + expect(query_aggregate.query.pipeline).to eq( + [ + { '$group' => { '_id' => {}, 'value' => { '$count' => {} } } }, + { '$project' => { '_id' => 0, 'group' => { '$literal' => {} }, 'value' => '$value' } } + ] + ) + end + + it 'add sum operation to the query' do + aggregation = Aggregation.new(operation: 'Sum', field: 'rating') + query_aggregate = described_class.new(collection, aggregation, Filter.new) + query_aggregate.get + + expect(query_aggregate.query.pipeline).to eq( + [ + { '$group' => { '_id' => {}, 'value' => { '$sum' => 'rating' } } }, + { '$project' => { '_id' => 0, 'group' => { '$literal' => {} }, 'value' => '$value' } } + ] + ) + end + + it 'add avg operation to the query' do + aggregation = Aggregation.new(operation: 'Avg', field: 'rating') + query_aggregate = described_class.new(collection, aggregation, Filter.new) + query_aggregate.get + + expect(query_aggregate.query.pipeline).to eq( + [ + { '$group' => { '_id' => {}, 'value' => { '$avg' => 'rating' } } }, + { '$project' => { '_id' => 0, 'group' => { '$literal' => {} }, 'value' => '$value' } } + ] + ) + end + + it 'add min operation to the query' do + aggregation = Aggregation.new(operation: 'Min', field: 'rating') + query_aggregate = described_class.new(collection, aggregation, Filter.new) + query_aggregate.get + + expect(query_aggregate.query.pipeline).to eq( + [ + { '$group' => { '_id' => {}, 'value' => { '$min' => 'rating' } } }, + { '$project' => { '_id' => 0, 'group' => { '$literal' => {} }, 'value' => '$value' } } + ] + ) + end + + it 'add max operation to the query' do + aggregation = Aggregation.new(operation: 'Max', field: 'rating') + query_aggregate = described_class.new(collection, aggregation, Filter.new) + query_aggregate.get + + expect(query_aggregate.query.pipeline).to eq( + [ + { '$group' => { '_id' => {}, 'value' => { '$max' => 'rating' } } }, + { '$project' => { '_id' => 0, 'group' => { '$literal' => {} }, 'value' => '$value' } } + ] + ) + end + end + end + end +end From c693d8de4e121a6d3b54111fc1684400bc723b28 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 31 Jan 2025 16:51:23 +0100 Subject: [PATCH 047/104] feat(flatten): add new utils --- .../options_parser.rb | 48 ++++++++++++++ .../utils/schema/mongoid_schema.rb | 62 +++++++++++++++++++ .../utils/version_manager.rb | 13 ++++ 3 files changed, 123 insertions(+) create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/version_manager.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb new file mode 100644 index 000000000..a05c00457 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb @@ -0,0 +1,48 @@ +module ForestAdminDatasourceMongoid + class OptionsParser + def self.parse_options(model, options) + schema = '' # MongoidSchema.from_model(model) TODO + + case options[:flatten_mode] + when 'auto' + get_auto_flatten_options(schema) + when 'manual' + get_manual_flatten_options(schema, options, model.name) + when 'none' + { as_fields: [], as_models: [] } + else + get_legacy_flatten_options(schema, options, model.name) + end + end + + class << self + def self.get_auto_flatten_options(schema); end + + def self.get_manual_flatten_options(schema, options, model_name); end + + # if (options?.asModels?.[modelName]) { + # // [legacy mode] retro-compatibility when customer provided asModels + # const cuts = new Set(options.asModels[modelName].map(f => f.replace(/:/g, '.'))); + # + # for (let field of cuts) { + # while (field.lastIndexOf('.') !== -1) { + # field = field.substring(0, field.lastIndexOf('.')); + # cuts.add(field); + # } + # } + # + # asFields = []; + # asModels = [...cuts].sort(); + # } else { + # // [legacy mode] retro-compatibility when customer did not provice anything + # asFields = []; + # asModels = Object.keys(schema.fields) + # .filter(field => schema.fields[field]?.['[]']?.options?.ref) + # .sort(); + # } + # + # return { asFields, asModels }; + def self.get_legacy_flatten_options(_schema, options, model_name); end + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb new file mode 100644 index 000000000..66179bd01 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb @@ -0,0 +1,62 @@ +module ForestAdminDatasourceMongoid + module Utils + module Schema + class MongoidSchema + attr_reader :is_array, :is_leaf, :fields + + def initialize(model, fields, is_array, is_leaf) + @model = model + @fields = fields + @is_array = is_array + @is_leaf = is_leaf + end + + def self.from_model(model) + fields = fields_and_embedded_relations(model) + + new(model, build_fields(fields), false, false) + end + + def self.fields_and_embedded_relations(model) + embedded_class = [Mongoid::Association::Embedded::EmbedsMany, Mongoid::Association::Embedded::EmbedsOne] + relations = model.relations.select { |_name, association| embedded_class.include?(association.class) } + + model.fields.merge(relations) + end + + def self.build_fields(schema_fields, level = 0) + targets = {} + + schema_fields.each do |name, field| + # start_with?("$") useless ?? + next if name.start_with?('$') || name.include?('__') || (name == '_id' && level.positive?) + + if VersionManager.sub_document?(field) + sub_targets = build_fields(fields_and_embedded_relations(field.klass), level + 1) + sub_targets.each { |sub_name, sub_field| recursive_set(targets, "#{name}.#{sub_name}", sub_field) } + elsif VersionManager.sub_document_array?(field) + sub_targets = build_fields(fields_and_embedded_relations(field.klass), level + 1) + sub_targets.each { |sub_name, sub_field| recursive_set(targets, "#{name}.[].#{sub_name}", sub_field) } + else + recursive_set(targets, name, field) + end + end + + targets + end + + def self.recursive_set(target, path, value) + index = path.index('.') + if index.nil? + target[path] = value + else + prefix = path[0, index] + suffix = path[index + 1, path.length] + target[prefix] ||= {} + recursive_set(target[prefix], suffix, value) + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/version_manager.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/version_manager.rb new file mode 100644 index 000000000..158995546 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/version_manager.rb @@ -0,0 +1,13 @@ +module ForestAdminDatasourceMongoid + module Utils + class VersionManager + def self.sub_document?(field) + field.is_a?(Mongoid::Association::Embedded::EmbedsOne) + end + + def self.sub_document_array?(field) + field.is_a?(Mongoid::Association::Embedded::EmbedsMany) + end + end + end +end From 1961ae92ffb3fee9194085d9ae7bd4ded8874685 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 3 Feb 2025 17:18:44 +0100 Subject: [PATCH 048/104] feat(flatten): option parser - auto flatten mode --- .../options_parser.rb | 71 +++++++++++-------- 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb index a05c00457..1cfa360d9 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb @@ -1,7 +1,7 @@ module ForestAdminDatasourceMongoid class OptionsParser def self.parse_options(model, options) - schema = '' # MongoidSchema.from_model(model) TODO + schema = ForestAdminDatasourceMongoid::Utils::Schema::MongoidSchema.from_model(model) case options[:flatten_mode] when 'auto' @@ -16,33 +16,48 @@ def self.parse_options(model, options) end class << self - def self.get_auto_flatten_options(schema); end - - def self.get_manual_flatten_options(schema, options, model_name); end - - # if (options?.asModels?.[modelName]) { - # // [legacy mode] retro-compatibility when customer provided asModels - # const cuts = new Set(options.asModels[modelName].map(f => f.replace(/:/g, '.'))); - # - # for (let field of cuts) { - # while (field.lastIndexOf('.') !== -1) { - # field = field.substring(0, field.lastIndexOf('.')); - # cuts.add(field); - # } - # } - # - # asFields = []; - # asModels = [...cuts].sort(); - # } else { - # // [legacy mode] retro-compatibility when customer did not provice anything - # asFields = []; - # asModels = Object.keys(schema.fields) - # .filter(field => schema.fields[field]?.['[]']?.options?.ref) - # .sort(); - # } - # - # return { asFields, asModels }; - def self.get_legacy_flatten_options(_schema, options, model_name); end + private + + def get_auto_flatten_options(schema) + forbidden_paths = schema.list_paths_matching(->(_, s) { !can_be_flattened(s) }) + + # Split on all arrays of objects and arrays of references. + as_models = schema.list_paths_matching(proc do |field, path_schema| + path_schema.is_array && + # FIX SCHEMA NODE OPTIONS REF + (!path_schema.is_leaf || path_schema.schema_node&.options&.ref) && + forbidden_paths.none? { |p| field == p || field.start_with?("#{p}.") } + end).sort + + # flatten all fields which are nested + as_fields = schema.list_paths_matching(proc do |field, path_schema| + # on veut flatten si on est à plus de 1 niveau de profondeur par rapport au asModels + min_distance = field.split('.').length + + as_models.each do |as_model| + if field.start_with?("#{as_model}.") + distance = field.split('.').length - as_model.split('.').length + min_distance = distance if distance < min_distance + end + end + + !as_models.include?(field) && path_schema.is_leaf && min_distance > 1 && forbidden_paths.none? do |p| + field.start_with?("#{p}.") + end + end) + + { as_fields: as_fields, as_models: as_models } + end + + def can_be_flattened(schema) + return true if schema.is_leaf + + !schema.fields.empty? + end + + def get_manual_flatten_options(schema, options, model_name); end + + def get_legacy_flatten_options(_schema, options, model_name); end end end end From a66a9956eaeb1134f7a7ee65c28a6dbce99f7ec3 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 3 Feb 2025 17:22:31 +0100 Subject: [PATCH 049/104] feat(flatten): handle list_path_matching --- .rubocop.yml | 2 + .../utils/schema/mongoid_schema.rb | 60 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/.rubocop.yml b/.rubocop.yml index 481b90795..18cecfbeb 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -255,6 +255,7 @@ Metrics/MethodLength: - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/form_factory.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb' Metrics/BlockLength: @@ -326,6 +327,7 @@ Layout/LineLength: - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/publication/publication_datasource_decorator.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator.rb' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/condition_tree_factory.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/collection.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/filter_factory.rb' diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb index 66179bd01..e2ec9a3af 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb @@ -2,15 +2,23 @@ module ForestAdminDatasourceMongoid module Utils module Schema class MongoidSchema + include ForestAdminDatasourceToolkit::Exceptions attr_reader :is_array, :is_leaf, :fields def initialize(model, fields, is_array, is_leaf) + @models = ObjectSpace.each_object(Class) + .select { |klass| klass < Mongoid::Document && klass.name && !klass.name.start_with?('Mongoid::') } + .to_h { |klass| [klass.name, klass] } @model = model @fields = fields @is_array = is_array @is_leaf = is_leaf end + def schema_node + @is_leaf ? @fields[:content] : @fields + end + def self.from_model(model) fields = fields_and_embedded_relations(model) @@ -56,6 +64,58 @@ def self.recursive_set(target, path, value) recursive_set(target[prefix], suffix, value) end end + + def list_paths_matching(handle, prefix = nil) + return [] if @is_leaf + + @fields.keys + .filter(&:present?) + .flat_map do |field| + schema = get_sub_schema(field) + sub_prefix = prefix ? "#{prefix}.#{field}" : field + sub_fields = schema.list_paths_matching(handle, sub_prefix) + sub_fields.map { |sub_field| "#{field}.#{sub_field}" } + handle.call(sub_prefix, schema) ? [field, *sub_fields] : sub_fields + end + end + + def get_sub_schema(path) + # Terminating condition + return self if path.blank? + + # General case: go down the tree + prefix, suffix = path.split(/\.(.*)/) + is_array = false + is_leaf = false + child = @fields[prefix] + + # Traverse relations + if child.is_a?(Hash) + relation_name = @model.relations[prefix].class_name + + # Traverse arrays + if child.is_a?(Hash) && child['[]'] + # (has_many embed) + child = child['[]'] + is_array = true + else + # (has_one embed) + child = MongoidSchema.from_model(@models[relation_name]).fields + end + + return MongoidSchema.new(@models[relation_name], child, is_array, is_leaf).get_sub_schema(suffix) + elsif child.nil? + raise ForestException, "Field '#{prefix}' not found. Available fields are: balbalblablalblalalbla" + end + + # We ended up on a field => box it. + if child.is_a? Mongoid::Fields::Standard + child = { content: child } + is_leaf = true + end + + MongoidSchema.new(@model, child, is_array, is_leaf).get_sub_schema(suffix) + end end end end From f31ccb1704a9564880da5df1325e1e3845f55048 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 3 Feb 2025 17:39:15 +0100 Subject: [PATCH 050/104] chore: add datasource --- .rubocop.yml | 2 + .../datasource.rb | 89 +++++++++++++++---- 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 18cecfbeb..1d5926eb1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -207,6 +207,7 @@ Metrics/ParameterLists: - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/dynamic_field.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/hook/context/after/hook_after_aggregate_context.rb' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/schema/relations/many_to_many_schema.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/schema/column_schema.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/caller.rb' @@ -327,6 +328,7 @@ Layout/LineLength: - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/publication/publication_datasource_decorator.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/relation/relation_collection_decorator.rb' - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/sort/sort_collection_decorator.rb' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/condition_tree/condition_tree_factory.rb' - 'packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/utils/collection.rb' diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb index e30345320..7db7dae95 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb @@ -5,35 +5,92 @@ module ForestAdminDatasourceMongoid class Datasource < ForestAdminDatasourceToolkit::Datasource attr_reader :models - def initialize - super - generate + def initialize(options: {}) + super() + + if options && !options[:flatten_mode] + ForestAdminAgent::Facades::Container.logger.log( + 'Warn', + 'Using unspecified flattenMode. ' \ + 'Please refer to the documentation to update your code: ' \ + 'https://docs.forestadmin.com/developer-guide-agents-ruby/data-sources/provided-data-sources/mongoid' + ) + end + + generate(options) end private - def generate + def generate(options) models = ObjectSpace.each_object(Class).select do |klass| - klass < Mongoid::Document && klass.name && !klass.name.start_with?('Mongoid::') && !embedded_in_relation?(klass) + klass < Mongoid::Document && klass.name && !klass.name.start_with?('Mongoid::') # && !embedded_in_relation?(klass) end + # options parser et schema models.each do |model| - add_collection(Collection.new(self, model)) + ForestAdminDatasourceMongoid::Utils::Schema::MongoidSchema.from_model(model) + OptionsParser.parse_options(model, options) end - end - def embedded_in_relation?(klass) - klass.relations.any? { |_name, association| association.is_a?(Mongoid::Association::Embedded::EmbeddedIn) } + # models.each do |model| + # add_collection(Collection.new(self, model)) + # end end - def fetch_primary_key(klass) - primary_key = klass.fields.find { |_, field| field.options[:identity] || field.name == '_id' }&.first - unless primary_key - raise(ForestAdminDatasourceToolkit::Exceptions::ForestException, - "Primary key not found for #{klass.name}") - end + # private addModel( + # model: Model, + # schema: MongooseSchema, + # stack: Stack, // current only + # prefix: string | null, // prefix that we should handle in this recursion + # asFields: string[], // current + children + # asModels: string[], // current + children + # ): void { + # const localAsFields = asFields.filter(f => !asModels.some(i => f.startsWith(`${i}.`))); + # const localAsModels = asModels.filter(f => !asModels.some(i => f.startsWith(`${i}.`))); + # const localStack = [...stack, { prefix, asFields: localAsFields, asModels: localAsModels }]; + # + # this.checkAsFields(schema, prefix, localAsFields); + # this.checkAsModels(schema, prefix, localAsModels); + # this.addCollection(new MongooseCollection(this, model, localStack)); + # + # for (const name of localAsModels) { + # const subPrefix = prefix ? `${prefix}.${name}` : name; + # const subAsFields = unnest(asFields, name); + # const subAsModels = unnest(asModels, name); + # + # this.addModel(model, schema, localStack, subPrefix, subAsFields, subAsModels); + # } + # } - primary_key + def add_model( + _model, + _schema, + stack, # current only + prefix, # prefix that we should handle in this recursion + as_fields, # current + children + as_models + ) # current + children + local_as_fields = as_fields.filter { |f| as_models.none? { |i| f.start_with?("#{i}.") } } + local_as_models = as_models.filter { |f| as_models.none? { |i| f.start_with?("#{i}.") } } + # peut etre faut faire un union parce qu'on pige rien ici de ce merdier + stack + [{ prefix: prefix, as_fields: local_as_fields, as_models: local_as_models }] end + + # TODO: REMOVE ?? + # def embedded_in_relation?(klass) + # klass.relations.any? { |_name, association| association.is_a?(Mongoid::Association::Embedded::EmbeddedIn) } + # end + + # TODO: REMOVE ?? + # def fetch_primary_key(klass) + # primary_key = klass.fields.find { |_, field| field.options[:identity] || field.name == '_id' }&.first + # unless primary_key + # raise(ForestAdminDatasourceToolkit::Exceptions::ForestException, + # "Primary key not found for #{klass.name}") + # end + # + # primary_key + # end end end From 172e869eba04d8e1fd3ce96bf8577434f4975ff3 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Tue, 4 Feb 2025 17:03:11 +0100 Subject: [PATCH 051/104] feat(collection): add fields with flatten mode auto --- .rubocop.yml | 1 + .../collection.rb | 245 +++++++++--------- .../datasource.rb | 127 +++++---- .../utils/helpers.rb | 32 +++ .../utils/schema/fields_generator.rb | 72 +++++ .../utils/schema/mongoid_schema.rb | 51 +++- 6 files changed, 359 insertions(+), 169 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb diff --git a/.rubocop.yml b/.rubocop.yml index 1d5926eb1..1a56c1adc 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -295,6 +295,7 @@ Metrics/ClassLength: - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/action_collection_decorator.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb' Style/OpenStructUse: Exclude: diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index 2de372552..415c20e3b 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -4,18 +4,25 @@ class Collection < ForestAdminDatasourceToolkit::Collection include Parser::Validation include Parser::Relation include ForestAdminDatasourceToolkit::Components::Query + include Utils::Helpers + include Utils::Schema MAX_DEPTH = 1 attr_reader :model - def initialize(datasource, model) + def initialize(datasource, model, stack) + prefix = stack[stack.length - 1][:prefix] + @model = model - name = format_model_name(@model.name) + @stack = stack + model_name = format_model_name(@model.name) + name = escape(prefix ? "#{prefix}.#{model_name}" : model_name) super(datasource, name) - fetch_fields - fetch_associations + add_fields(FieldsGenerator.build_fields_schema(model, stack)) + # fetch_fields + # fetch_associations enable_count end @@ -49,120 +56,120 @@ def format_model_name(class_name) class_name.gsub('::', '__') end - def fetch_fields - # Standard fields - @model.fields.each do |column_name, column| - field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( - column_type: get_column_type(column), - filter_operators: operators_for_column_type(get_column_type(column)), - is_primary_key: column.object_id_field? && column.association.nil?, - is_read_only: false, - is_sortable: true, - default_value: column.object_id_field? ? nil : get_default_value(column), - enum_values: [], - validations: get_validations(column) - ) - - add_field(column_name, field) - end - - # Embedded field (EmbedsMany and EmbedsOne) - get_embedded_fields(@model).each do |column_name, column| - field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( - column_type: get_column_type(column), - is_primary_key: false, - is_sortable: false - ) - add_field(column_name, field) - end - end - - def fetch_associations - @model.relations.transform_values do |association| - case association - when Mongoid::Association::Referenced::HasMany - if association.polymorphic? - add_field( - association.name.to_s, - ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicOneToManySchema.new( - foreign_collection: format_model_name(association.klass.name), - origin_key: association.foreign_key, - origin_key_target: association.primary_key, - origin_type_field: association.type, - origin_type_value: association.inverse_class_name.constantize - ) - ) - else - add_field( - association.name.to_s, - ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema.new( - foreign_collection: format_model_name(association.klass.name), - origin_key: association.foreign_key, - origin_key_target: association.primary_key - ) - ) - end - when Mongoid::Association::Referenced::BelongsTo - if association.polymorphic? - foreign_collections = get_polymorphic_types(association.name) - add_field( - association.name.to_s, - ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicManyToOneSchema.new( - foreign_collections: foreign_collections.keys, - foreign_key: association.foreign_key, - foreign_key_type_field: association.inverse_type, - foreign_key_targets: foreign_collections - ) - ) - schema[:fields][association.foreign_key].is_read_only = true - schema[:fields][association.inverse_type].is_read_only = true - else - add_field( - association.name.to_s, - ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema.new( - foreign_collection: format_model_name(association.klass.name), - foreign_key: association.foreign_key, - foreign_key_target: association.primary_key - ) - ) - end - when Mongoid::Association::Referenced::HasOne - if association.polymorphic? - add_field( - association.name.to_s, - ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicOneToOneSchema.new( - foreign_collection: format_model_name(association.klass.name), - origin_key: association.foreign_key, - origin_key_target: association.primary_key, - origin_type_field: association.type, - origin_type_value: association.inverse_class_name.constantize - ) - ) - else - add_field( - association.name.to_s, - ForestAdminDatasourceToolkit::Schema::Relations::OneToOneSchema.new( - foreign_collection: format_model_name(association.klass.name), - origin_key: association.foreign_key, - origin_key_target: association.primary_key - ) - ) - end - when Mongoid::Association::Referenced::HasAndBelongsToMany - foreign_key_of_association = association.klass.reflect_on_all_associations.find do |assoc| - assoc.klass == association.inverse_class_name.constantize - end&.foreign_key - - add_field( - association.name.to_s, - ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema.new( - foreign_collection: format_model_name(association.klass.name), - origin_key: foreign_key_of_association, - origin_key_target: association.primary_key - ) - ) - end - end - end + # def fetch_fields + # # Standard fields + # @model.fields.each do |column_name, column| + # field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( + # column_type: get_column_type(column), + # filter_operators: operators_for_column_type(get_column_type(column)), + # is_primary_key: column.object_id_field? && column.association.nil?, + # is_read_only: false, + # is_sortable: true, + # default_value: column.object_id_field? ? nil : get_default_value(column), + # enum_values: [], + # validations: get_validations(column) + # ) + # + # add_field(column_name, field) + # end + # + # # Embedded field (EmbedsMany and EmbedsOne) + # get_embedded_fields(@model).each do |column_name, column| + # field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( + # column_type: get_column_type(column), + # is_primary_key: false, + # is_sortable: false + # ) + # add_field(column_name, field) + # end + # end + + # def fetch_associations + # @model.relations.transform_values do |association| + # case association + # when Mongoid::Association::Referenced::HasMany + # if association.polymorphic? + # add_field( + # association.name.to_s, + # ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicOneToManySchema.new( + # foreign_collection: format_model_name(association.klass.name), + # origin_key: association.foreign_key, + # origin_key_target: association.primary_key, + # origin_type_field: association.type, + # origin_type_value: association.inverse_class_name.constantize + # ) + # ) + # else + # add_field( + # association.name.to_s, + # ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema.new( + # foreign_collection: format_model_name(association.klass.name), + # origin_key: association.foreign_key, + # origin_key_target: association.primary_key + # ) + # ) + # end + # when Mongoid::Association::Referenced::BelongsTo + # if association.polymorphic? + # foreign_collections = get_polymorphic_types(association.name) + # add_field( + # association.name.to_s, + # ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicManyToOneSchema.new( + # foreign_collections: foreign_collections.keys, + # foreign_key: association.foreign_key, + # foreign_key_type_field: association.inverse_type, + # foreign_key_targets: foreign_collections + # ) + # ) + # schema[:fields][association.foreign_key].is_read_only = true + # schema[:fields][association.inverse_type].is_read_only = true + # else + # add_field( + # association.name.to_s, + # ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema.new( + # foreign_collection: format_model_name(association.klass.name), + # foreign_key: association.foreign_key, + # foreign_key_target: association.primary_key + # ) + # ) + # end + # when Mongoid::Association::Referenced::HasOne + # if association.polymorphic? + # add_field( + # association.name.to_s, + # ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicOneToOneSchema.new( + # foreign_collection: format_model_name(association.klass.name), + # origin_key: association.foreign_key, + # origin_key_target: association.primary_key, + # origin_type_field: association.type, + # origin_type_value: association.inverse_class_name.constantize + # ) + # ) + # else + # add_field( + # association.name.to_s, + # ForestAdminDatasourceToolkit::Schema::Relations::OneToOneSchema.new( + # foreign_collection: format_model_name(association.klass.name), + # origin_key: association.foreign_key, + # origin_key_target: association.primary_key + # ) + # ) + # end + # when Mongoid::Association::Referenced::HasAndBelongsToMany + # foreign_key_of_association = association.klass.reflect_on_all_associations.find do |assoc| + # assoc.klass == association.inverse_class_name.constantize + # end&.foreign_key + # + # add_field( + # association.name.to_s, + # ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema.new( + # foreign_collection: format_model_name(association.klass.name), + # origin_key: foreign_key_of_association, + # origin_key_target: association.primary_key + # ) + # ) + # end + # end + # end end end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb index 7db7dae95..9bd349eb5 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb @@ -3,6 +3,8 @@ module ForestAdminDatasourceMongoid class Datasource < ForestAdminDatasourceToolkit::Datasource + include Utils::Helpers + attr_reader :models def initialize(options: {}) @@ -24,48 +26,24 @@ def initialize(options: {}) def generate(options) models = ObjectSpace.each_object(Class).select do |klass| - klass < Mongoid::Document && klass.name && !klass.name.start_with?('Mongoid::') # && !embedded_in_relation?(klass) + klass < Mongoid::Document && klass.name && !klass.name.start_with?('Mongoid::') && !embedded_in_relation?(klass) end - # options parser et schema + # Create collections (with only many to one relations). models.each do |model| ForestAdminDatasourceMongoid::Utils::Schema::MongoidSchema.from_model(model) - OptionsParser.parse_options(model, options) + options_parser = OptionsParser.parse_options(model, options) + + add_model(model, schema, [], nil, options_parser[:as_fields], options_parser[:as_models]) end - # models.each do |model| - # add_collection(Collection.new(self, model)) - # end + # Add one-to-many, one-to-one and many-to-many relations. + # RelationGenerator.addImplicitRelations(this.collections); end - # private addModel( - # model: Model, - # schema: MongooseSchema, - # stack: Stack, // current only - # prefix: string | null, // prefix that we should handle in this recursion - # asFields: string[], // current + children - # asModels: string[], // current + children - # ): void { - # const localAsFields = asFields.filter(f => !asModels.some(i => f.startsWith(`${i}.`))); - # const localAsModels = asModels.filter(f => !asModels.some(i => f.startsWith(`${i}.`))); - # const localStack = [...stack, { prefix, asFields: localAsFields, asModels: localAsModels }]; - # - # this.checkAsFields(schema, prefix, localAsFields); - # this.checkAsModels(schema, prefix, localAsModels); - # this.addCollection(new MongooseCollection(this, model, localStack)); - # - # for (const name of localAsModels) { - # const subPrefix = prefix ? `${prefix}.${name}` : name; - # const subAsFields = unnest(asFields, name); - # const subAsModels = unnest(asModels, name); - # - # this.addModel(model, schema, localStack, subPrefix, subAsFields, subAsModels); - # } - # } - def add_model( - _model, - _schema, + model, + schema, stack, # current only prefix, # prefix that we should handle in this recursion as_fields, # current + children @@ -74,23 +52,74 @@ def add_model( local_as_fields = as_fields.filter { |f| as_models.none? { |i| f.start_with?("#{i}.") } } local_as_models = as_models.filter { |f| as_models.none? { |i| f.start_with?("#{i}.") } } # peut etre faut faire un union parce qu'on pige rien ici de ce merdier - stack + [{ prefix: prefix, as_fields: local_as_fields, as_models: local_as_models }] + local_stack = stack + [{ prefix: prefix, as_fields: local_as_fields, as_models: local_as_models }] + + add_collection(Collection.new(self, model, local_stack)) + + local_as_models.each do |name| + sub_prefix = prefix ? "#{prefix}.#{name}" : name + sub_as_fields = unnest(as_fields, name) + sub_as_models = unnest(as_models, name) + + add_model(model, schema, local_stack, sub_prefix, sub_as_fields, sub_as_models) + end + end + + def check_as_fields(schema, prefix, local_as_fields) + local_schema = schema.get_sub_schema(prefix) + local_as_fields.each do |field| + name = prefix ? "#{prefix}.#{field}" : field + + if !field.include?('.') && prefix + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, + "asFields contains '#{name}', which can't be flattened further because " \ + "asModels contains '#{prefix}', so it is already at the root of a collection." + end + + unless field.include?('.') + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, + "asFields contains '${name}', which can't be flattened because it is already at " \ + 'the root of the model.' + end + + next unless contains_intermediary_array(local_schema, field) + + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, + "asFields contains '${name}', " \ + "which can't be moved to the root of the model, because it is inside of an array. " \ + 'Either add all intermediary arrays to asModels, or remove it from asFields.' + end end - # TODO: REMOVE ?? - # def embedded_in_relation?(klass) - # klass.relations.any? { |_name, association| association.is_a?(Mongoid::Association::Embedded::EmbeddedIn) } - # end - - # TODO: REMOVE ?? - # def fetch_primary_key(klass) - # primary_key = klass.fields.find { |_, field| field.options[:identity] || field.name == '_id' }&.first - # unless primary_key - # raise(ForestAdminDatasourceToolkit::Exceptions::ForestException, - # "Primary key not found for #{klass.name}") - # end - # - # primary_key - # end + def check_as_models(schema, prefix, local_as_models) + local_schema = schema.get_sub_schema(prefix) + + local_as_models.each do |field| + name = prefix ? "#{prefix}.#{field}" : field + + next unless contains_intermediary_array(local_schema, field) + + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, + "asModels contains '#{name}', " \ + "which can't be transformed into a model, because it is inside of an array. " \ + 'Either add all intermediary arrays to asModels, or remove it from asModels.' + end + end + + def contains_intermediary_array(_local_schema, _field) + index = field.index('.') + + while index != -1 + prefix = field[0, index] + + return true if schema.get_sub_schema(prefix).is_array + + index = field.index('.', index + 1) + end + end + + def embedded_in_relation?(klass) + klass.relations.any? { |_name, association| association.is_a?(Mongoid::Association::Embedded::EmbeddedIn) } + end end end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb new file mode 100644 index 000000000..20cef7edc --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb @@ -0,0 +1,32 @@ +module ForestAdminDatasourceMongoid + module Utils + module Helpers + # Similar to projection.unnest + # @example + # unnest(['firstname', 'book.title', 'book.author'], 'book') == ['title', 'author'] + def unnest(strings, prefix) + strings.select { |field| field.start_with?("#{prefix}.") }.map { |field| field[prefix.size + 1..] } + end + + def escape(str) + str.tr('.', '_') + end + + def recursive_delete(target, path) + index = path.index('.') + + if index.nil? + target.delete(path) + else + prefix = path[0, index] + suffix = path[index + 1..] + + if target.is_a?(Hash) && target.key?(prefix) + recursive_delete(target[prefix], suffix) + target.delete(prefix) if target[prefix].empty? + end + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb new file mode 100644 index 000000000..ae00892dd --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb @@ -0,0 +1,72 @@ +module ForestAdminDatasourceMongoid + module Utils + module Schema + class FieldsGenerator + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + extend Utils::Helpers + extend Parser::Column + + def self.build_fields_schema(model, stack) + our_schema = {} + child_schema = MongoidSchema.from_model(model).apply_stack(stack) + + child_schema.fields.each do |name, field| + next unless name != 'parent' + + our_schema[name] = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( + column_type: get_column_type(field), + filter_operators: operators_for_column_type(get_column_type(field)), + is_primary_key: field.object_id_field? && field.association.nil?, + is_read_only: false, + is_sortable: get_column_type(field) != 'Json', + default_value: field.object_id_field? ? nil : get_default_value(field), + enum_values: [], + validations: [] # get_validations(field) + ) + + # TODO: check BelongsTo embed + # if (field instanceof SchemaType && field.options.ref) { + # ourSchema[`${name}__manyToOne`] = this.buildManyToOne(field.options.ref, name); + # } + end + + return our_schema unless stack.length > 1 + + parent_prefix = stack[stack.length - 2][:prefix] + + our_schema['_id'] = build_virtual_primary_key + parent_id = child_schema.fields['parent']['_id'] + our_schema['parent_id'] = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( + column_type: get_column_type(parent_id), + filter_operators: operators_for_column_type(get_column_type(parent_id)), + is_primary_key: false, + is_read_only: false, + is_sortable: get_column_type(parent_id) != 'Json', + default_value: parent_id.object_id_field? ? nil : get_default_value(parent_id), + enum_values: [], + validations: [{ operator: 'Present' }] + ) + + model_name = model.name.gsub('::', '__') + our_schema['parent'] = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema.new( + foreign_collection: escape(parent_prefix.nil? ? model_name : "#{model_name}.#{parent_prefix}"), + foreign_key: 'parent_id', + foreign_key_target: '_id' + ) + + our_schema + end + + def self.build_virtual_primary_key + ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( + column_type: 'String', + filter_operators: operators_for_column_type('String'), + is_primary_key: true, + is_read_only: true, + is_sortable: true + ) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb index e2ec9a3af..9db8a8055 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb @@ -3,6 +3,7 @@ module Utils module Schema class MongoidSchema include ForestAdminDatasourceToolkit::Exceptions + include Utils::Helpers attr_reader :is_array, :is_leaf, :fields def initialize(model, fields, is_array, is_leaf) @@ -75,7 +76,8 @@ def list_paths_matching(handle, prefix = nil) sub_prefix = prefix ? "#{prefix}.#{field}" : field sub_fields = schema.list_paths_matching(handle, sub_prefix) sub_fields.map { |sub_field| "#{field}.#{sub_field}" } - handle.call(sub_prefix, schema) ? [field, *sub_fields] : sub_fields + # debugger + handle.call(sub_prefix, schema) ? [sub_prefix, *sub_fields] : sub_fields end end @@ -116,6 +118,53 @@ def get_sub_schema(path) MongoidSchema.new(@model, child, is_array, is_leaf).get_sub_schema(suffix) end + + def apply_stack(stack, skip_as_models: false) + raise ForestException, 'Stack can never be empty.' if stack.empty? + + step = stack.pop + sub_schema = get_sub_schema(step[:prefix]) + + step[:as_fields].each do |field| + field_schema = sub_schema.get_sub_schema(field) + recursive_delete(sub_schema.fields, field) + + sub_schema.fields[field.gsub('.', '@@@')] = if field_schema.is_array + { '[]' => field_schema.schema_node } + else + field_schema.schema_node + end + end + + unless stack.empty? + sub_schema.fields['_id'] = Mongoid::Fields::Standard.new('__placeholder__', { type: String }) + sub_schema.fields['parent'] = apply_stack(stack).fields + sub_schema.fields['parent_id'] = sub_schema.fields['parent']['_id'] + end + + if skip_as_models + # Here we actually should recurse into the subSchema and add the _id and parentId fields + # to the virtual one-to-one relations. + # + # The issue is that we can't do that because we don't know where the relations are after + # the first level of nesting (we would need to have the complete asModel / asFields like in + # the datasource.ts file). + # + # Because of that, we need to work around the missing fields in: + # - pipeline/virtual-fields.ts file: we're throwing an error when we can't guess the value + # of a given _id / parentId field. + # - pipeline/filter.ts: we're using an educated guess for the types of the _id / parentId + # fields (String or ObjectId) + else + step[:as_models].each do |field| + recursive_delete(@fields, field) + end + end + + stack << step + + sub_schema + end end end end From 88f2ea31d07748e0cf7bb40b038b06d587a79a74 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 5 Feb 2025 16:40:10 +0100 Subject: [PATCH 052/104] feat: list_fields on schema --- .../utils/schema/mongoid_schema.rb | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb index 9db8a8055..1754795a6 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb @@ -107,7 +107,7 @@ def get_sub_schema(path) return MongoidSchema.new(@models[relation_name], child, is_array, is_leaf).get_sub_schema(suffix) elsif child.nil? - raise ForestException, "Field '#{prefix}' not found. Available fields are: balbalblablalblalalbla" + raise ForestException, "Field '#{prefix}' not found. Available fields are: #{list_fields}" end # We ended up on a field => box it. @@ -165,6 +165,24 @@ def apply_stack(stack, skip_as_models: false) sub_schema end + + # List leafs and arrays up to a certain level + # Arrays are never traversed + def list_fields(level = Float::INFINITY) + raise ForestException, 'Cannot list fields on a leaf schema.' if @is_leaf + raise ForestException, 'Level must be greater than 0.' if level.zero? + + return @fields.keys if level == 1 + + @fields.keys.flat_map do |field| + schema = get_sub_schema(field) + if schema.is_leaf || schema.is_array + [field] + else + schema.list_fields(level - 1).map { |sub_field| "#{field}.#{sub_field}" } + end + end + end end end end From 2f390f65042fb08d5a34a1b8a6a03eee20818ef7 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Wed, 5 Feb 2025 17:17:45 +0100 Subject: [PATCH 053/104] feat(mongo): add pipeline constructor --- .rubocop.yml | 2 + .../collection.rb | 47 ++++- .../datasource.rb | 2 +- .../utils/pipeline/condition_generator.rb | 30 ++++ .../utils/pipeline/filter_generator.rb | 166 ++++++++++++++++++ .../utils/pipeline/lookup_generator.rb | 91 ++++++++++ .../utils/pipeline/projection_generator.rb | 20 +++ .../utils/pipeline/reparent_generator.rb | 107 +++++++++++ .../utils/schema/fields_generator.rb | 2 +- .../components/query/projection.rb | 4 +- 10 files changed, 461 insertions(+), 10 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/condition_generator.rb create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/projection_generator.rb create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator.rb diff --git a/.rubocop.yml b/.rubocop.yml index 1a56c1adc..356900948 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -257,6 +257,7 @@ Metrics/MethodLength: - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb' Metrics/BlockLength: @@ -296,6 +297,7 @@ Metrics/ClassLength: - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb' Style/OpenStructUse: Exclude: diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index 415c20e3b..a1d10eb41 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -6,10 +6,9 @@ class Collection < ForestAdminDatasourceToolkit::Collection include ForestAdminDatasourceToolkit::Components::Query include Utils::Helpers include Utils::Schema + include Utils::Pipeline - MAX_DEPTH = 1 - - attr_reader :model + attr_reader :model, :stack def initialize(datasource, model, stack) prefix = stack[stack.length - 1][:prefix] @@ -27,9 +26,11 @@ def initialize(datasource, model, stack) end def list(_caller, filter, projection) - Utils::Query.new(self, projection, filter) - .get - .map { |record| Utils::MongoidSerializer.new(record).to_hash(projection) } + projection = projection.union(filter.condition_tree&.projection || [], filter.sort&.projection || []) + pipeline = [*build_base_pipeline(filter, projection), *ProjectionGenerator.project(projection)] + # return addNullValues(replaceMongoTypes(await this.model.aggregate(pipeline)), projection); + + model.unscoped.collection.aggregate(pipeline).to_a end def aggregate(_caller, filter, aggregation, limit = nil) @@ -52,6 +53,40 @@ def delete(_caller, filter) private + def build_base_pipeline(filter, projection) + fields_used_in_filters = FilterGenerator.list_relations_used_in_filter(filter) + + pre_sort_and_paginate, + sort_and_paginate_post_filtering, + sort_and_paginate_all = FilterGenerator.sort_and_paginate(model, filter) + + reparent_stages = ReparentGenerator.reparent(model, stack) + + # For performance reasons, we want to only include the relationships that are used in filters + # before applying the filters + lookup_used_in_filters_stage = LookupGenerator.lookup(model, stack, projection, + { include: fields_used_in_filters }) + filter_stage = FilterGenerator.filter(model, stack, filter) + # Here are the remaining relationships that are not used in filters. For performance reasons + # they are computed after the filters. + lookup_not_filtered_stage = LookupGenerator.lookup( + model, + stack, + projection, + { exclude: fields_used_in_filters } + ) + + [ + *pre_sort_and_paginate, + *reparent_stages, + *lookup_used_in_filters_stage, + *filter_stage, + *sort_and_paginate_post_filtering, + *lookup_not_filtered_stage, + *sort_and_paginate_all + ] + end + def format_model_name(class_name) class_name.gsub('::', '__') end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb index 9bd349eb5..17335793a 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb @@ -52,7 +52,7 @@ def add_model( local_as_fields = as_fields.filter { |f| as_models.none? { |i| f.start_with?("#{i}.") } } local_as_models = as_models.filter { |f| as_models.none? { |i| f.start_with?("#{i}.") } } # peut etre faut faire un union parce qu'on pige rien ici de ce merdier - local_stack = stack + [{ prefix: prefix, as_fields: local_as_fields, as_models: local_as_models }] + local_stack = stack.union([{ prefix: prefix, as_fields: local_as_fields, as_models: local_as_models }]) add_collection(Collection.new(self, model, local_stack)) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/condition_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/condition_generator.rb new file mode 100644 index 000000000..bf5d03428 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/condition_generator.rb @@ -0,0 +1,30 @@ +module ForestAdminDatasourceMongoid + module Utils + module Pipeline + class ConditionGenerator + include Utils::Schema + + FOREST_RECORD_DOES_NOT_EXIST = 'FOREST_RECORD_DOES_NOT_EXIST'.freeze + + def self.tag_record_if_not_exist(field, then_expr) + if_missing(field, then_expr, { FOREST_RECORD_DOES_NOT_EXIST => true }) + end + + def self.tag_record_if_not_exist_by_value(field, then_expr) + if_missing(field, then_expr, FOREST_RECORD_DOES_NOT_EXIST) + end + + def self.if_missing(field, then_expr, else_expr) + { + '$cond' => { + 'if' => { '$and' => [{ '$ne' => [{ '$type' => "$#{field}" }, 'missing'] }, + { '$ne' => ["$#{field}", nil] }] }, + 'then' => then_expr, + 'else' => else_expr + } + } + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb new file mode 100644 index 000000000..2c6e556cf --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb @@ -0,0 +1,166 @@ +module ForestAdminDatasourceMongoid + module Utils + module Pipeline + class FilterGenerator + include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + + def self.sort_and_paginate(model, filter) + sort = compute_sort(filter.sort) + + sort_and_limit = [] + + sort_and_limit << { '$sort' => sort } if sort + + if filter.page + sort_and_limit << { '$skip' => filter.page.offset } + sort_and_limit << { '$limit' => filter.page.limit } + end + + return [sort_and_limit, [], []] unless sort + + all_sort_criteria_native = sort.keys.none? do |key| + !model.fields.key?(key) + end + + # if sort applies to native fields and no filters are applied (very common case) + # we apply pre-sort + limit at the beginning of the pipeline (to improve perf) + return [sort_and_limit, [], []] if all_sort_criteria_native && filter.condition_tree.nil? + + all_condition_tree_keys_native = filter.condition_tree&.projection&.none? do |key| + !model.fields.key?(key) + end + + # if filters apply to native fields only, we can apply the sort right after filtering + return [[], sort_and_limit, []] if all_sort_criteria_native && all_condition_tree_keys_native + + # if sorting apply to relations, it is safer to do it at the end of the pipeline + [[], [], sort_and_limit] + end + + def self.filter(model, stack, filter) + fields = {} + tree = filter.condition_tree + match = compute_match(model, stack, tree, fields) + + pipeline = [] + pipeline << compute_fields(fields) unless fields.empty? + pipeline << { '$match' => match } if match + + pipeline + end + + def self.list_relations_used_in_filter(filter) + fields = [] + + filter.sort&.each do |clause| + list_paths(clause[:field]).each { |field| fields << field } + end + + list_fields_used_in_filter_tree(filter.condition_tree, fields) + + fields + end + + def self.list_fields_used_in_filter_tree(condition_tree, fields) + if condition_tree.is_a? Nodes::ConditionTreeBranch + condition_tree.conditions.each { |condition| list_fields_used_in_filter_tree(condition, fields) } + elsif condition_tree&.field&.include?(':') + list_paths(condition_tree.field).each { |field| fields << field } + end + end + + def self.list_paths(field) + parts = field.split(':') + + parts.slice(0..)&.map&.with_index { |_, index| parts.slice(0, index + 1).join('.') } + end + + def self.compute_sort(sort) + return if sort.empty? + + result = {} + + sort.each do |clause| + formatted_field = format_nested_field_path(clause[:field]) + result[formatted_field] = clause[:ascending] ? 1 : -1 + end + + result + end + + def self.format_nested_field_path(field) + field.tr(':', '.') + end + + def self.compute_match(model, stack, tree, fields) + schema = Utils::Schema::MongoidSchema.from_model(model).apply_stack(stack, skip_as_models: true) + + if tree.is_a? Nodes::ConditionTreeBranch + # to check + return { + "$#{tree.aggregator.downcase}" => tree.conditions.map do |condition| + compute_match(model, stack, condition, fields) + end + } + end + + if tree.is_a? Nodes::ConditionTreeLeaf + value = format_and_cast_leaf_value(schema, tree, fields) + condition = build_match_condition(tree.operator, value) + + return { format_nested_field_path(tree.field) => condition } + end + + nil + end + + def self.format_and_cast_leaf_value(_schema, tree, _fields) + # TODO + + tree.value + end + + def self.build_match_condition(operator, value) + case operator + when Operators::GREATER_THAN + { '$gt' => value } + when Operators::LESS_THAN + { '$lt' => value } + when Operators::EQUAL + { '$eq' => value } + when Operators::NOT_EQUAL + { '$ne' => value } + when Operators::IN + { '$in' => value } + when Operators::INCLUDES_ALL + { '$all' => value } + when Operators::NOT_CONTAINS + { '$not' => Regexp.new("^.*#{value}.*$") } + when Operators::NOT_I_CONTAINS + { '$not' => Regexp.new("^.*#{value}.*$", 'i') } + when Operators::MATCH + { '$regex' => value } + when Operators::PRESENT + { '$exists' => true, '$ne' => null } + else + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, "Unsupported '#{operator}' operator" + end + end + + def self.compute_fields(fields) + fields.reduce({ '$addFields' => {} }) do |computed, field| + string_field = format_string_field_name(field) + computed['$addField'][string_field] = { '$toString' => "$#{field}" } + end + end + + def self.format_string_field_name(field) + parts = field.split('.') + parts << "string_#{parts.pop}" + + parts.join('.') + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb new file mode 100644 index 000000000..63fbc24db --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb @@ -0,0 +1,91 @@ +module ForestAdminDatasourceMongoid + module Utils + module Pipeline + class LookupGenerator + include Utils::Schema + + # Transform a forest admin projection into a mongo pipeline that performs the lookups + # and transformations to target them + def self.lookup(model, stack, projection, options) + stack.each_with_index.reduce([MongoidSchema.from_model(model)]) do |acc, (_, index)| + [ + *acc, + MongoidSchema.from_model(model).apply_stack(stack.slice(0..index + 1), skip_as_models: true) + ] + end + + lookup_projection(nil, stack.map { |s| s[:fields] }, projection, options) + end + + def self.lookup_projection(current_path, schema_stack, projection, options) + pipeline = [] + fields = {} + + projection.relations.each do |name, relation_projection| + pipeline << lookup_relation(current_path, schema_stack, name, relation_projection, options) + fields.merge!(add_fields(name, relation_projection, options)) + end + + pipeline + end + + def self.add_fields(name, projection, options) + return {} if options[:include] && !options[:include].include?(name) + return {} if options[:exclude]&.include?(name) + + projection.filter { |field| field.include?('@@@') } + .map { |field_name| "#{name}.#{field_name.tr(":", ".")}" } + .each_with_object({}) do |curr, acc| + acc[curr] = "$#{curr.tr("@@@", ".")}" + end + end + + def self.lookup_relation(current_path, schema_stack, name, projection, options) + ObjectSpace.each_object(Class) + .select { |klass| klass < Mongoid::Document && klass.name && !klass.name.start_with?('Mongoid::') } + .to_h { |klass| [klass.name, klass] } + as = current_path ? "#{current_path}.#{name}" : name + + last_schema = schema_stack[schema_stack.length - 1] + previous_schema = schema_stack.slice(0..schema_stack.length - 1) + + return [] if options[:include] && !options[:include].include?(as) + return [] if options[:exclude]&.include?(as) + + # Native many to one relation + # TODO + # if (name.endsWith('__manyToOne')) { + # const foreignKeyName = name.substring(0, name.length - '__manyToOne'.length); + # const model = models[lastSchema[foreignKeyName].options.ref]; + # + # const from = model.collection.collectionName; + # const localField = currentPath ? `${currentPath}.${foreignKeyName}` : foreignKeyName; + # const foreignField = '_id'; + # + # const subSchema = MongooseSchema.fromModel(model).fields; + # + # return [ + # // Push lookup to pipeline + # { $lookup: { from, localField, foreignField, as } }, + # { $unwind: { path: `$${as}`, preserveNullAndEmptyArrays: true } }, + # + # // Recurse to get relations of relations + # ...this.lookupProjection(models, as, [...schemaStack, subSchema], subProjection, options), + # ]; + # } + + # inverse of fake relation + if name == 'parent' && !previous_schema.empty? + return lookup_projection(as, previous_schema, projection, options) + end + + # fake relation + return lookup_projection(as, [*schema_stack, last_schema[name]], projection, options) if last_schema[name] + + # We should have handled all possible cases. + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, "Unexpected relation: '#{name}'" + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/projection_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/projection_generator.rb new file mode 100644 index 000000000..25eccb3a5 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/projection_generator.rb @@ -0,0 +1,20 @@ +module ForestAdminDatasourceMongoid + module Utils + module Pipeline + class ProjectionGenerator + def self.project(projection) + return [{ '$replaceRoot' => { 'newRoot' => { '$literal' => {} } } }] if projection.empty? + + project = { '_id' => false, 'FOREST_RECORD_DOES_NOT_EXIST' => true } + + projection.each do |field| + formatted_field = field.tr(':', '.') + project[formatted_field] = true + end + + [{ '$project' => project }] + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator.rb new file mode 100644 index 000000000..7a90e1bf0 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator.rb @@ -0,0 +1,107 @@ +module ForestAdminDatasourceMongoid + module Utils + module Pipeline + # Generate pipeline to query submodels. + # The operations make rotations in the documents so that the root is changed to the submodel + # without loosing the parent (which may be needed later on). + class ReparentGenerator + include Utils::Schema + + def self.reparent(model, stack) + schema = MongoidSchema.from_model(model) + + stack.flat_map.with_index do |step, index| + return unflatten(step[:as_fields]) if index.zero? + + local_schema = schema.get_sub_schema(step[:prefix]) + relative_prefix = if stack[index - 1][:prefix].nil? + step[:prefix] + else + step[:prefix][stack[index - 1][:prefix].length + 1..] + end + + [ + *(if local_schema.is_array + reparent_array(relative_prefix, + local_schema.is_leaf) + else + reparent_object(relative_prefix, + local_schema.is_leaf) + end), + *unflatten(step[:as_fields]) + ] + end + end + + def self.reparent_array(prefix, in_doc) + [ + { '$unwind' => { 'path' => "$#{prefix}", 'includeArrayIndex' => 'index' } }, + { + '$replaceRoot' => { + 'newRoot' => { + '$mergeObjects' => [ + in_doc ? { 'content' => "$#{prefix}" } : "$#{prefix}", + ConditionGenerator.tag_record_if_not_exist( + prefix, + { + '_id' => { '$concat' => [{ '$toString' => '$_id' }, ".#{prefix}.", + { '$toString' => '$index' }] }, + 'parentId' => '$_id', + 'parent' => '$$ROOT' + } + ) + ] + } + } + } + ] + end + + def self.reparent_object(prefix, in_doc) + [ + { + '$replaceRoot' => { + 'newRoot' => { + '$mergeObjects' => [ + in_doc ? { 'content' => "$#{prefix}" } : "$#{prefix}", + ConditionGenerator.tag_record_if_not_exist( + prefix, + { + '_id' => { '$concat' => [{ '$toString' => '$_id' }, ".#{prefix}"] }, + 'parentId' => '$_id', + 'parent' => '$$ROOT' + } + ) + ] + } + } + } + ] + end + + def self.unflatten(as_fields) + return [] if as_fields.empty? + + unflatten_results = [] + add_fields = as_fields.map { |f| [f.gsub('.', '@@@'), "$#{f}"] } + # DocumentDB limits the addFields stage to 30 fields. + chunk_size = 30 + + # TODO: refactor if/else + if add_fields.length > chunk_size + (0..(add_fields.length - 1).step(chunk_size)).each do |i| + chunk = add_fields.slice(i..i + chunk_size) + unflatten_results << { '$addFields' => chunk } + end + else + unflatten_results << { '$addFields' => add_fields.to_h } + end + + unflatten_results << { '$project' => as_fields.to_h { |f| [f, 0] } } + + unflatten_results + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb index ae00892dd..cfaf8d329 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb @@ -16,7 +16,7 @@ def self.build_fields_schema(model, stack) our_schema[name] = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( column_type: get_column_type(field), filter_operators: operators_for_column_type(get_column_type(field)), - is_primary_key: field.object_id_field? && field.association.nil?, + is_primary_key: name == '_id', is_read_only: false, is_sortable: get_column_type(field) != 'Json', default_value: field.object_id_field? ? nil : get_default_value(field), diff --git a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb index 71203b65b..c71f396e2 100644 --- a/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb +++ b/packages/forest_admin_datasource_toolkit/lib/forest_admin_datasource_toolkit/components/query/projection.rb @@ -85,8 +85,8 @@ def re_project(record) result end - def union(other_arrays) - Projection.new(other_arrays.to_a.union(self)) + def union(*other_arrays) + Projection.new(to_a.union(*other_arrays)) end end end From 1a13f0d00186caf0fd8e034bfcc265b3df908ae6 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 5 Feb 2025 17:24:08 +0100 Subject: [PATCH 054/104] feat: add implicit relations --- .../datasource.rb | 2 +- .../utils/schema/relation_generator.rb | 54 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/relation_generator.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb index 17335793a..8811caf18 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb @@ -38,7 +38,7 @@ def generate(options) end # Add one-to-many, one-to-one and many-to-many relations. - # RelationGenerator.addImplicitRelations(this.collections); + ForestAdminDatasourceMongoid::Utils::Schema::RelationGenerator.add_implicit_relations(@collections) end def add_model( diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/relation_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/relation_generator.rb new file mode 100644 index 000000000..d5ea54e9e --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/relation_generator.rb @@ -0,0 +1,54 @@ +module ForestAdminDatasourceMongoid + module Utils + module Schema + class RelationGenerator + include ForestAdminDatasourceToolkit::Schema::Relations + extend ForestAdminDatasourceMongoid::Utils::Helpers + + def self.add_implicit_relations(collections) + collections.each_value do |collection| + many_to_ones = collection.schema[:fields].select { |_, f| f.type == 'ManyToOne' } + + many_to_ones.each do |(name, field)| + add_many_to_one_inverse(collection, name, field) + end + end + end + + # Given any many to one relation, generated while parsing mongoose schema, generate the + # inverse relationship on the foreignCollection. + # /!\ The inverse can be a OneToOne, or a ManyToOne + def self.add_many_to_one_inverse(collection, name, schema) + type = nil + inverse_name = nil + + if name == 'parent' + # Create inverse of 'parent' relationship so that the relation name matches the actual name + # of the data which is stored in the database. + stack = collection.stack + prefix = stack[stack.length - 1][:prefix] + is_array = MongoidSchema.from_model(collection.model).apply_stack(stack).is_array + + type = is_array ? OneToManySchema : OneToOneSchema + inverse_name = escape(prefix) + + if stack.length > 2 + previous_length = stack[stack.length - 2][:prefix].length + 1 + inverse_name = prefix[previous_length..] + end + else + inverse_name = escape("#{collection.name}_#{name}__inverse") + type = OneToManySchema + end + + other_collection = collection.datasource.get_collection(schema.foreign_collection) + other_collection.schema[:fields][inverse_name] = type.new( + foreign_collection: collection.name, + origin_key: schema.foreign_key, + origin_key_target: schema.foreign_key_target + ) + end + end + end + end +end From 826ea9b257bed4c6ed8639b8e6f3b953839a3f94 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 5 Feb 2025 17:32:20 +0100 Subject: [PATCH 055/104] refactor: relation generator --- .../utils/schema/relation_generator.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/relation_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/relation_generator.rb index d5ea54e9e..0d513f731 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/relation_generator.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/relation_generator.rb @@ -19,9 +19,6 @@ def self.add_implicit_relations(collections) # inverse relationship on the foreignCollection. # /!\ The inverse can be a OneToOne, or a ManyToOne def self.add_many_to_one_inverse(collection, name, schema) - type = nil - inverse_name = nil - if name == 'parent' # Create inverse of 'parent' relationship so that the relation name matches the actual name # of the data which is stored in the database. From e569384213c494f4ebecf7e33851b94c12d5b545 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Wed, 5 Feb 2025 17:42:35 +0100 Subject: [PATCH 056/104] fix(pipeline): compute sort --- .../utils/pipeline/filter_generator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb index 2c6e556cf..13e47d967 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb @@ -76,7 +76,7 @@ def self.list_paths(field) end def self.compute_sort(sort) - return if sort.empty? + return if sort.nil? || sort.empty? result = {} From c8c651794d6a79e1c1863f5f569a515af7b7b64d Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 5 Feb 2025 17:59:54 +0100 Subject: [PATCH 057/104] chore: prepare flatten mode manual --- .../options_parser.rb | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb index 1cfa360d9..d3ba80518 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb @@ -4,14 +4,12 @@ def self.parse_options(model, options) schema = ForestAdminDatasourceMongoid::Utils::Schema::MongoidSchema.from_model(model) case options[:flatten_mode] - when 'auto' - get_auto_flatten_options(schema) when 'manual' get_manual_flatten_options(schema, options, model.name) when 'none' { as_fields: [], as_models: [] } else - get_legacy_flatten_options(schema, options, model.name) + get_auto_flatten_options(schema) end end @@ -55,7 +53,27 @@ def can_be_flattened(schema) !schema.fields.empty? end - def get_manual_flatten_options(schema, options, model_name); end + def get_manual_flatten_options(schema, options, model_name) + as_models = (options[:flatten_options]&.[](model_name)&.[](:as_models) || []) + .map { |f| f.tr(':', '.') } + .sort + + as_fields = (options[:flatten_options]&.[](model_name)&.[](:as_fields) || []) + .flat_map do |item| + field = (item.is_a?(String) ? item : item[:field]).tr(':', '.') + level = item.is_a?(String) ? 99 : item[:level] + sub_schema = schema.get_sub_schema(field) + + if sub_schema.is_leaf + [field] + else + sub_schema.list_fields(level).map { |f| "#{field}.#{f}" } + end + end + as_fields = as_fields.reject { |f| as_models.include?(f) }.sort + + { as_fields: as_fields, as_models: as_models } + end def get_legacy_flatten_options(_schema, options, model_name); end end From c8679693038fd74ee43fcafa415556de0e751b0a Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 6 Feb 2025 17:30:34 +0100 Subject: [PATCH 058/104] feat: handle filter format_and_cast_leaf_value --- .../utils/helpers.rb | 16 ++++++ .../utils/pipeline/filter_generator.rb | 51 +++++++++++++++++-- .../utils/schema/mongoid_schema.rb | 6 +++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb index 20cef7edc..16226844f 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb @@ -27,6 +27,22 @@ def recursive_delete(target, path) end end end + + # not sure it this method is relevant for mongoid + def replace_mongo_types(data) + case data + when BSON::ObjectId + data.to_s + when Date + data.iso8601 + when Array + data.map { |item| replace_mongo_types(item) } + when Hash + data.transform_values { |value| replace_mongo_types(value) } + else + data + end + end end end end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb index 13e47d967..070827993 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb @@ -4,6 +4,8 @@ module Pipeline class FilterGenerator include ForestAdminDatasourceToolkit::Components::Query::ConditionTree + STRING_OPERATORS = [Operators::MATCH, Operators::NOT_CONTAINS, Operators::NOT_I_CONTAINS].freeze + def self.sort_and_paginate(model, filter) sort = compute_sort(filter.sort) @@ -38,7 +40,7 @@ def self.sort_and_paginate(model, filter) end def self.filter(model, stack, filter) - fields = {} + fields = [] tree = filter.condition_tree match = compute_match(model, stack, tree, fields) @@ -114,10 +116,31 @@ def self.compute_match(model, stack, tree, fields) nil end - def self.format_and_cast_leaf_value(_schema, tree, _fields) - # TODO + def self.format_and_cast_leaf_value(schema, leaf, fields) + value = leaf.value + leaf = leaf.override(field: format_nested_field_path(leaf.field)) + is_array, instance = get_field_metadata(schema, leaf.field) + + if is_array + if instance == Date && value.is_a?(Array) && value.all? { |v| valid_iso_date?(v) } + value = value.map { |v| Date.parse(v) } + elsif instance == BSON::ObjectId && value.is_a?(Array) && value.all? { |v| BSON::ObjectId.legal?(v) } + value = value.map { |id| BSON::ObjectId.from_string(id) } + end + elsif instance == BSON::ObjectId + if STRING_OPERATORS.include?(leaf.operator) + fields << leaf.field + leaf.override(field: format_string_field_name(leaf.field)) + elsif value.is_a?(Array) && value.all? { |v| BSON::ObjectId.legal?(v) } + value = value.map { |id| BSON::ObjectId.from_string(id) } + elsif BSON::ObjectId.legal?(value) + value = BSON::ObjectId.from_string(value) + end + elsif instance == Date && valid_iso_date?(value) + value = Date.parse(value) + end - tree.value + value end def self.build_match_condition(operator, value) @@ -160,6 +183,26 @@ def self.format_string_field_name(field) parts.join('.') end + + def self.get_field_metadata(schema, field) + begin + sub_schema = schema.get_sub_schema(field) + is_array = sub_schema.is_array + instance = sub_schema.schema_type.type + rescue StandardError + is_array = false + instance = 'String' + end + + [is_array, instance] + end + + def self.valid_iso_date?(value) + DateTime.iso8601(value) + true + rescue ArgumentError + false + end end end end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb index 1754795a6..1c50bc96a 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb @@ -20,6 +20,12 @@ def schema_node @is_leaf ? @fields[:content] : @fields end + def schema_type + raise ForestException, 'Schema is not a leaf.' unless @is_leaf + + @fields[:content] + end + def self.from_model(model) fields = fields_and_embedded_relations(model) From 5ca5d057f3fbc68da10d710a7f048a72e1bd3479 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 7 Feb 2025 10:02:09 +0100 Subject: [PATCH 059/104] feat: add add_null_values utils --- .../collection.rb | 5 +- .../utils/add_null_values.rb | 54 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/add_null_values.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index a1d10eb41..2481d61c3 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -7,6 +7,7 @@ class Collection < ForestAdminDatasourceToolkit::Collection include Utils::Helpers include Utils::Schema include Utils::Pipeline + include Utils::AddNullValues attr_reader :model, :stack @@ -28,9 +29,9 @@ def initialize(datasource, model, stack) def list(_caller, filter, projection) projection = projection.union(filter.condition_tree&.projection || [], filter.sort&.projection || []) pipeline = [*build_base_pipeline(filter, projection), *ProjectionGenerator.project(projection)] - # return addNullValues(replaceMongoTypes(await this.model.aggregate(pipeline)), projection); - model.unscoped.collection.aggregate(pipeline).to_a + add_null_values(replace_mongo_types(model.collection.aggregate(pipeline).to_a), projection) + # model.unscoped.collection.aggregate(pipeline).to_a end def aggregate(_caller, filter, aggregation, limit = nil) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/add_null_values.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/add_null_values.rb new file mode 100644 index 000000000..65f88bd2c --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/add_null_values.rb @@ -0,0 +1,54 @@ +module ForestAdminDatasourceMongoid + module Utils + module AddNullValues + # Filter out records that have been tagged as not existing + # If the key FOREST_RECORD_DOES_NOT_EXIST is present in the record, the record is removed + # If a nested object has a key with FOREST_RECORD_DOES_NOT_EXIST, the nested object is removed + def remove_not_exist_record(record) + return nil if record.nil? || record[Pipeline::ConditionGenerator::FOREST_RECORD_DOES_NOT_EXIST] + + record.each do |key, value| + if value.is_a?(Hash) && value.value?(Pipeline::ConditionGenerator::FOREST_RECORD_DOES_NOT_EXIST) + record[key] = nil + end + end + + record + end + + def add_null_values_on_record(record, projection) + return nil if record.nil? + + result = record.dup + + projection.each do |field| + field_prefix = field.split(':').first + result[field_prefix] ||= nil + end + + nested_prefixes = projection.select { |field| field.include?(':') }.map { |field| field.split(':').first }.uniq + + nested_prefixes.each do |nested_prefix| + child_paths = projection.fitler { |field| field.start_with?("#{nested_prefix}:") } + .map { |field| field[nested_prefix.size + 1..] } + + next unless result[nested_prefix] && !result[nested_prefix].nil? + + if result[nested_prefix].is_a?(Array) + result[nested_prefix] = result[nested_prefix].map do |child_record| + add_null_values_on_record(child_record, child_paths) + end + elsif result[nested_prefix].is_a?(Hash) + result[nested_prefix] = add_null_values_on_record(result[nested_prefix], child_paths) + end + end + + remove_not_exist_record(result) + end + + def add_null_values(records, projection) + records.filter_map { |record| add_null_values_on_record(record, projection) } + end + end + end +end From 3efb1f287daa34a66c676f47e0debf085304b8fe Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 7 Feb 2025 17:01:53 +0100 Subject: [PATCH 060/104] feat: finalize aggregate route and add group-generator utils --- .../collection.rb | 12 ++- .../utils/pipeline/group_generator.rb | 86 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/group_generator.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index 2481d61c3..26e7e591a 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -35,7 +35,17 @@ def list(_caller, filter, projection) end def aggregate(_caller, filter, aggregation, limit = nil) - Utils::QueryAggregate.new(self, aggregation, filter, limit).get + lookup_projection = aggregation.projection.union(filter.condition_tree&.projection || []) + pipeline = [ + *build_base_pipeline(filter, lookup_projection), + *GroupGenerator.group(aggregation), + { '$sort' => { value: -1 } } + ] + pipeline << { '$limit' => limit } if limit + rows = model.collection.aggregate(pipeline).to_a + + replace_mongo_types(rows) + # Utils::QueryAggregate.new(self, aggregation, filter, limit).get end def create(_caller, data) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/group_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/group_generator.rb new file mode 100644 index 000000000..5d6abbc09 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/group_generator.rb @@ -0,0 +1,86 @@ +module ForestAdminDatasourceMongoid + module Utils + module Pipeline + class GroupGenerator + AGGREGATION_OPERATION = { + 'Sum' => '$sum', + 'Avg' => '$avg', + 'Count' => '$sum', + 'Max' => '$max', + 'Min' => '$min' + }.freeze + + GROUP_OPERATION = { + 'Year' => '%Y-01-01', + 'Month' => '%Y-%m-01', + 'Day' => '%Y-%m-%d', + 'Week' => '%Y-%m-%d' + }.freeze + + def self.group(aggregation) + [ + { + '$group' => { + _id: compute_groups(aggregation.groups), + value: compute_value(aggregation) + } + }, + { + '$project' => { + '_id' => 0, + 'value' => '$value', + 'group' => compute_groups_projection(aggregation.groups) + } + } + ] + end + + class << self + private + + def compute_value(aggregation) + # Handle count(*) case + return { '$sum' => 1 } if aggregation.field.nil? + + # General case + field = "$#{aggregation.field.tr(":", ".")}" + + if aggregation.operation == 'Count' + { '$sum' => { '$cond' => [{ '$ne' => [field, nil] }, 1, 0] } } + else + { AGGREGATION_OPERATION[aggregation.operation] => field } + end + end + + def compute_groups(groups) + return nil if groups.nil? || groups.empty? + + groups.reduce({}) do |memo, group| + field = "$#{group[:field].tr(":", ".")}" + + if group[:operation] + if group[:operation] == 'Week' + date = { '$dateTrunc' => { 'date' => field, 'startOfWeek' => 'Monday', 'unit' => 'week' } } + field = { '$dateToString' => { 'format' => GROUP_OPERATION[group[:operation]], 'date' => date } } + else + field = { '$dateToString' => { 'format' => GROUP_OPERATION[group[:operation]], 'date' => field } } + end + end + + memo.merge(group[:field] => field) + end + end + + # Move fields in _id to the root of the document + def compute_groups_projection(groups) + return { '$literal' => {} } if groups.nil? || groups.empty? + + groups.each_with_object({}) do |group, memo| + memo[group[:field]] = "$_id.#{group[:field]}" + end + end + end + end + end + end +end From 86c2ca39862f2136262d97c94dd57e101ef8174e Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 11 Feb 2025 14:17:30 +0100 Subject: [PATCH 061/104] feat(mongoid): handle create --- .../collection.rb | 50 ++++++++++++++++--- .../utils/helpers.rb | 35 +++++++++++++ 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index 26e7e591a..7619286ac 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -1,13 +1,14 @@ module ForestAdminDatasourceMongoid class Collection < ForestAdminDatasourceToolkit::Collection + include ForestAdminDatasourceToolkit::Components::Query + include ForestAdminDatasourceToolkit::Exceptions include Parser::Column - include Parser::Validation include Parser::Relation - include ForestAdminDatasourceToolkit::Components::Query - include Utils::Helpers + include Parser::Validation + include Utils::AddNullValues include Utils::Schema include Utils::Pipeline - include Utils::AddNullValues + include Utils::Helpers attr_reader :model, :stack @@ -48,8 +49,29 @@ def aggregate(_caller, filter, aggregation, limit = nil) # Utils::QueryAggregate.new(self, aggregation, filter, limit).get end - def create(_caller, data) - Utils::MongoidSerializer.new(model.create(data)).to_hash(ProjectionFactory.all(self)) + def create(caller, data) + handle_validation_error { _create(caller, data) } + # Utils::MongoidSerializer.new(model.create(data)).to_hash(ProjectionFactory.all(self)) + end + + def _create(_caller, flat_data) + as_fields = @stack[stack.length - 1][:as_fields] + data = unflatten_record(flat_data, as_fields) + + if @stack.length < 2 + inserted_record = @model.create(data) + + return { '_id' => inserted_record.attributes['_id'], **flat_data } + end + + # Only array fields can create subdocuments (the others should use update) + schema = MongoidSchema.from_model(@model).apply_stack(@stack) + + if schema.is_array + create_for_array_subfield(data, flat_data, schema) + else + create_for_object_subfield(data, flat_data) + end end def update(_caller, filter, data) @@ -102,6 +124,22 @@ def format_model_name(class_name) class_name.gsub('::', '__') end + def create_for_array_subfield(data, flat_data, schema) + # TODO + end + + def create_for_object_subfield(data, _flat_data) + # TODO + raise ValidationError, 'Trying to create multiple subrecords at once' if data.length > 1 + raise ValidationError, 'Trying to create without data' if data.empty? + end + + def handle_validation_error + yield + rescue Mongoid::Errors::Validations => e + raise ForestAdminDatasourceToolkit::Exceptions::ValidationError, e.message + end + # def fetch_fields # # Standard fields # @model.fields.each do |column_name, column| diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb index 16226844f..4cc95442b 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb @@ -12,6 +12,18 @@ def escape(str) str.tr('.', '_') end + def recursive_set(target, path, value) + index = path.index('.') + if index.nil? + target[path] = value + else + prefix = path[0, index] + suffix = path[index + 1, path.length] + target[prefix] ||= {} + recursive_set(target[prefix], suffix, value) + end + end + def recursive_delete(target, path) index = path.index('.') @@ -43,6 +55,29 @@ def replace_mongo_types(data) data end end + + # Unflattend patches and records + def unflatten_record(record, as_fields, patch_mode: false) + new_record = record.dup + + as_fields.each do |field| + alias_field = field.gsub('.', '@@@') + + value = new_record[alias_field] + + next if value.nil? + + if patch_mode + new_record[field] = value + else + recursive_set(new_record, field, value) + end + + new_record.delete(alias_field) + end + + new_record + end end end end From b1e275c357e0d0e8ad3e6c4d48c1829a04d807b2 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 11 Feb 2025 17:09:44 +0100 Subject: [PATCH 062/104] feat(mongoid): handle simple update / delete --- .../lib/forest_admin_datasource_mongoid/datasource.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb index 8811caf18..59d8151e7 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb @@ -31,10 +31,12 @@ def generate(options) # Create collections (with only many to one relations). models.each do |model| + # if model.name == 'Mongo::User' ForestAdminDatasourceMongoid::Utils::Schema::MongoidSchema.from_model(model) options_parser = OptionsParser.parse_options(model, options) add_model(model, schema, [], nil, options_parser[:as_fields], options_parser[:as_models]) + # end end # Add one-to-many, one-to-one and many-to-many relations. From 02e811a3a28b5cfdbadd06b03a71bd3be8556045 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 11 Feb 2025 17:14:20 +0100 Subject: [PATCH 063/104] feat(mongoid): handle simple update / delete --- .../collection.rb | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index 7619286ac..5a9235e49 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -74,14 +74,47 @@ def _create(_caller, flat_data) end end - def update(_caller, filter, data) - entity = Utils::Query.new(self, nil, filter).build.first - entity&.update(data) + def update(caller, filter, data) + handle_validation_error { _update(caller, filter, data) } + # entity = Utils::Query.new(self, nil, filter).build.first + # entity&.update(data) end - def delete(_caller, filter) - entities = Utils::Query.new(self, nil, filter).build - entities&.each(&:destroy) + def _update(_caller, filter, flat_patch) + as_fields = @stack[stack.length - 1][:as_fields] + patch = unflatten_record(flat_patch, as_fields, patch_mode: true) + + records = list(nil, filter, Projection.new(['_id'])) + ids = records.map { |record| record['_id'] } + + if @stack.length < 2 + if ids.length > 1 + @model.where(_id: ids).update_all(patch) + else + @model.find(ids.first).update(patch) + end + end + + # TODO: Other cases + nil + end + + def delete(caller, filter) + handle_validation_error { _delete(caller, filter) } + # entities = Utils::Query.new(self, nil, filter).build + # entities&.each(&:destroy) + end + + def _delete(_caller, filter) + records = list(nil, filter, Projection.new(['_id'])) + ids = records.map { |record| record['_id'] } + + @model.where(_id: ids).delete_all if @stack.length < 2 + + # TODO: Other cases + # schema = MongoidSchema.from_model(@model).apply_stack(@stack) + # ids_by_path = group_ids_by_path(ids) + nil end private From db0b448b6e9356fdb74fdae296154dd33dd65a11 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 12 Feb 2025 15:22:34 +0100 Subject: [PATCH 064/104] fix: update on embed relations --- .../collection.rb | 5 +++-- .../utils/helpers.rb | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index 5a9235e49..f677fb0bc 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -83,15 +83,16 @@ def update(caller, filter, data) def _update(_caller, filter, flat_patch) as_fields = @stack[stack.length - 1][:as_fields] patch = unflatten_record(flat_patch, as_fields, patch_mode: true) + formatted_patch = reformat_patch(patch) records = list(nil, filter, Projection.new(['_id'])) ids = records.map { |record| record['_id'] } if @stack.length < 2 if ids.length > 1 - @model.where(_id: ids).update_all(patch) + @model.where(_id: ids).update_all(formatted_patch) else - @model.find(ids.first).update(patch) + @model.find(ids.first).update(formatted_patch) end end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb index 4cc95442b..2b40bfd35 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb @@ -78,6 +78,28 @@ def unflatten_record(record, as_fields, patch_mode: false) new_record end + + def reformat_patch(patch) + patch.each_with_object({}) do |(key, value), result| + keys = key.split('.') + last_key = keys.pop + nested_hash = keys.reverse.inject({ last_key => value }) do |hash, k| + { k => hash } + end + deep_merge(result, nested_hash) + end + end + + def deep_merge(target, source) + source.each do |key, value| + if target[key].is_a?(Hash) && value.is_a?(Hash) + deep_merge(target[key], value) + else + target[key] ||= value + end + end + target + end end end end From 247d2960a50dad6f3d4bb6aaeaca9f791d6e2d83 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 14 Feb 2025 10:41:58 +0100 Subject: [PATCH 065/104] fix(utils): add_null_values --- .../lib/forest_admin_datasource_mongoid/datasource.rb | 2 -- .../utils/add_null_values.rb | 7 ++++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb index 59d8151e7..8811caf18 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/datasource.rb @@ -31,12 +31,10 @@ def generate(options) # Create collections (with only many to one relations). models.each do |model| - # if model.name == 'Mongo::User' ForestAdminDatasourceMongoid::Utils::Schema::MongoidSchema.from_model(model) options_parser = OptionsParser.parse_options(model, options) add_model(model, schema, [], nil, options_parser[:as_fields], options_parser[:as_models]) - # end end # Add one-to-many, one-to-one and many-to-many relations. diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/add_null_values.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/add_null_values.rb index 65f88bd2c..04daac80e 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/add_null_values.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/add_null_values.rb @@ -4,17 +4,22 @@ module AddNullValues # Filter out records that have been tagged as not existing # If the key FOREST_RECORD_DOES_NOT_EXIST is present in the record, the record is removed # If a nested object has a key with FOREST_RECORD_DOES_NOT_EXIST, the nested object is removed + # rubocop:disable Style/ConditionalAssignment def remove_not_exist_record(record) return nil if record.nil? || record[Pipeline::ConditionGenerator::FOREST_RECORD_DOES_NOT_EXIST] record.each do |key, value| if value.is_a?(Hash) && value.value?(Pipeline::ConditionGenerator::FOREST_RECORD_DOES_NOT_EXIST) record[key] = nil + else + # TODO : TEST PROPERLY - Récursion pour les sous-objets + record[key] = remove_not_exist_record(value) end end record end + # rubocop:enable Style/ConditionalAssignment def add_null_values_on_record(record, projection) return nil if record.nil? @@ -29,7 +34,7 @@ def add_null_values_on_record(record, projection) nested_prefixes = projection.select { |field| field.include?(':') }.map { |field| field.split(':').first }.uniq nested_prefixes.each do |nested_prefix| - child_paths = projection.fitler { |field| field.start_with?("#{nested_prefix}:") } + child_paths = projection.filter { |field| field.start_with?("#{nested_prefix}:") } .map { |field| field[nested_prefix.size + 1..] } next unless result[nested_prefix] && !result[nested_prefix].nil? From 725fea6132577ab5873e143406539fcb36d4a3f6 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 17 Feb 2025 10:21:15 +0100 Subject: [PATCH 066/104] fix: add_null_values --- .../forest_admin_datasource_mongoid/utils/add_null_values.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/add_null_values.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/add_null_values.rb index 04daac80e..43f943d56 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/add_null_values.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/add_null_values.rb @@ -4,22 +4,17 @@ module AddNullValues # Filter out records that have been tagged as not existing # If the key FOREST_RECORD_DOES_NOT_EXIST is present in the record, the record is removed # If a nested object has a key with FOREST_RECORD_DOES_NOT_EXIST, the nested object is removed - # rubocop:disable Style/ConditionalAssignment def remove_not_exist_record(record) return nil if record.nil? || record[Pipeline::ConditionGenerator::FOREST_RECORD_DOES_NOT_EXIST] record.each do |key, value| if value.is_a?(Hash) && value.value?(Pipeline::ConditionGenerator::FOREST_RECORD_DOES_NOT_EXIST) record[key] = nil - else - # TODO : TEST PROPERLY - Récursion pour les sous-objets - record[key] = remove_not_exist_record(value) end end record end - # rubocop:enable Style/ConditionalAssignment def add_null_values_on_record(record, projection) return nil if record.nil? From fadbd4e12ab9fed55ae3fe09ad95569ee584b094 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 17 Feb 2025 11:37:31 +0100 Subject: [PATCH 067/104] fix: tests --- packages/forest_admin_datasource_mongoid/Gemfile-test | 1 + .../lib/forest_admin_datasource_mongoid/datasource_spec.rb | 4 +++- .../parser/relation_spec.rb | 7 ++++++- .../parser/validation_spec.rb | 7 ++++++- .../forest_admin_datasource_mongoid/spec/spec_helper.rb | 1 + 5 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/Gemfile-test b/packages/forest_admin_datasource_mongoid/Gemfile-test index 8de2d4f8a..fc57c2e2d 100644 --- a/packages/forest_admin_datasource_mongoid/Gemfile-test +++ b/packages/forest_admin_datasource_mongoid/Gemfile-test @@ -8,6 +8,7 @@ gem 'rubocop', '~> 1.21' gem 'mongoid', '~> 9.0' group :development, :test do + gem 'forest_admin_agent', path: '../forest_admin_agent' gem 'forest_admin_datasource_toolkit', path: '../forest_admin_datasource_toolkit' gem 'mongoid-rspec', '~> 4.0' gem 'rails' diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb index c8f4e7997..c701f968c 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb @@ -29,7 +29,9 @@ module ForestAdminDatasourceMongoid end it 'raises an exception if trying to add a collection with an existing name' do - expect { datasource.add_collection(Collection.new(datasource, Post)) } + logger = instance_double(Logger, log: nil) + allow(ForestAdminAgent::Facades::Container).to receive(:logger).and_return(logger) + expect { datasource.add_collection(Collection.new(datasource, Post, [{ prefix: nil, as_fields: [], as_models: [] }])) } .to raise_error(ForestException, '🌳🌳🌳 Collection Post already defined in datasource') end end diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/relation_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/relation_spec.rb index d3a6089b9..68d2d15c4 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/relation_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/relation_spec.rb @@ -4,8 +4,13 @@ module ForestAdminDatasourceMongoid module Parser include ForestAdminDatasourceToolkit::Components::Query::ConditionTree describe Validation do + before do + logger = instance_double(Logger, log: nil) + allow(ForestAdminAgent::Facades::Container).to receive(:logger).and_return(logger) + end + let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new } - let(:collection) { ForestAdminDatasourceMongoid::Collection.new(datasource, model_class) } + let(:collection) { ForestAdminDatasourceMongoid::Collection.new(datasource, model_class, [{ prefix: nil, as_fields: [], as_models: [] }]) } context 'when models with polymorphic relations exist' do let(:model_class) { User } diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/validation_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/validation_spec.rb index 20d9893aa..f9012148d 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/validation_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/validation_spec.rb @@ -4,8 +4,13 @@ module ForestAdminDatasourceMongoid module Parser include ForestAdminDatasourceToolkit::Components::Query::ConditionTree describe Validation do + before do + logger = instance_double(Logger, log: nil) + allow(ForestAdminAgent::Facades::Container).to receive(:logger).and_return(logger) + end + let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new } - let(:collection) { ForestAdminDatasourceMongoid::Collection.new(datasource, model) } + let(:collection) { ForestAdminDatasourceMongoid::Collection.new(datasource, model, [{ prefix: nil, as_fields: [], as_models: [] }]) } context 'when the model has a before_validation callback' do let(:model) do diff --git a/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb b/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb index ec635bdb0..9b6485430 100644 --- a/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb +++ b/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb @@ -1,6 +1,7 @@ require 'simplecov' require 'simplecov_json_formatter' require 'simplecov-html' +require 'forest_admin_agent' require 'forest_admin_datasource_toolkit' require 'forest_admin_datasource_mongoid' require 'mongoid-rspec' From 358481013f784e87c2c30fd07721ca71fea5ae01 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 17 Feb 2025 11:41:29 +0100 Subject: [PATCH 068/104] fix: test --- .../collection_spec.rb | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb index 63730cf65..448041ef9 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb @@ -2,14 +2,18 @@ module ForestAdminDatasourceMongoid include ForestAdminDatasourceToolkit::Components::Query - describe Collection do + before do + logger = instance_double(Logger, log: nil) + allow(ForestAdminAgent::Facades::Container).to receive(:logger).and_return(logger) + end + let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new } - let(:collection_post) { described_class.new(datasource, Post) } - let(:collection_user) { described_class.new(datasource, User) } - let(:collection_departure) { described_class.new(datasource, Departure) } - let(:collection_comment) { described_class.new(datasource, Comment) } - let(:collection_band) { described_class.new(datasource, Band) } + let(:collection_post) { described_class.new(datasource, Post, [{ prefix: nil, as_fields: [], as_models: [] }]) } + let(:collection_user) { described_class.new(datasource, User, [{ prefix: nil, as_fields: [], as_models: [] }]) } + let(:collection_departure) { described_class.new(datasource, Departure, [{ prefix: nil, as_fields: [], as_models: [] }]) } + let(:collection_comment) { described_class.new(datasource, Comment, [{ prefix: nil, as_fields: [], as_models: [] }]) } + let(:collection_band) { described_class.new(datasource, Band, [{ prefix: nil, as_fields: [], as_models: [] }]) } it 'initializes with the correct model name' do expect(collection_post.name).to eq('Post') From c64eee8c98564ddd6e5d9f786fd960038f061a88 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 17 Feb 2025 17:50:46 +0100 Subject: [PATCH 069/104] fix(filter): list_relations and add tests --- .../utils/pipeline/filter_generator.rb | 15 +- .../utils/filter_generator_spec.rb | 148 ++++++++++++++++++ 2 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/filter_generator_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb index 070827993..171306ca1 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb @@ -52,15 +52,24 @@ def self.filter(model, stack, filter) end def self.list_relations_used_in_filter(filter) - fields = [] + fields = Set.new filter.sort&.each do |clause| - list_paths(clause[:field]).each { |field| fields << field } + next unless clause[:field].include?(':') # only relations (fields containing ':') + + list_paths(clause[:field]).each do |field| + parts = field.split('.') # Split into nested levels ("author.country.name"→ ["author", "country", "name"]) + + (1...parts.length).each do |i| + parent_field = parts[0...i].join('.') + fields.add(parent_field) + end + end end list_fields_used_in_filter_tree(filter.condition_tree, fields) - fields + fields.to_a end def self.list_fields_used_in_filter_tree(condition_tree, fields) diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/filter_generator_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/filter_generator_spec.rb new file mode 100644 index 000000000..d56d407ed --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/filter_generator_spec.rb @@ -0,0 +1,148 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + module Utils + module Pipeline + include ForestAdminDatasourceToolkit::Components::Query + describe FilterGenerator do + before do + logger = instance_double(Logger, log: nil) + allow(ForestAdminAgent::Facades::Container).to receive(:logger).and_return(logger) + end + + let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new } + let(:model) { datasource.get_collection('Post').model } + let(:stack) { [{ prefix: nil, as_fields: [], as_models: [] }] } + + describe '.filter' do + describe 'Condition tree' do + it 'filter should generate a $match stage' do + filter = Filter.new( + condition_tree: ConditionTree::Nodes::ConditionTreeLeaf.new('title', 'Match', /^Foundation$/i) + ) + pipeline = described_class.filter(model, stack, filter) + expect(pipeline).to eq([{ '$match' => { 'title' => { '$regex' => /^Foundation$/i } } }]) + end + + it 'filter should generate a $addFields and $match stage' do + filter = Filter.new( + condition_tree: ConditionTree::Nodes::ConditionTreeLeaf.new('author:id', 'NotContains', 'something') + ) + comment = datasource.get_collection('Comment').model + pipeline = described_class.filter(comment, stack, filter) + expect(pipeline).to eq([ + { '$match' => { 'author.id' => { '$not' => /^.*something.*$/ } } } + ]) + end + + it 'filter should generate a $match stage with $and and $or nodes' do + filter = Filter.new( + condition_tree: ConditionTree::Nodes::ConditionTreeBranch.new('And', [ + ConditionTree::Nodes::ConditionTreeLeaf.new('author:lastname', 'Equal', 'Asimov'), + ConditionTree::Nodes::ConditionTreeBranch.new('Or', [ + ConditionTree::Nodes::ConditionTreeLeaf.new('author:firstname', 'Equal', 'Isaac'), + ConditionTree::Nodes::ConditionTreeLeaf.new('author:firstname', 'Equal', 'John') + ]) + ]) + ) + pipeline = described_class.filter(model, stack, filter) + expect(pipeline).to eq([ + { + '$match' => { + '$and' => [ + { 'author.lastname' => { '$eq' => 'Asimov' } }, + { + '$or' => [ + { 'author.firstname' => { '$eq' => 'Isaac' } }, + { 'author.firstname' => { '$eq' => 'John' } } + ] + } + ] + } + } + ]) + end + end + + describe 'Skip & Limit' do + it 'generates the relevant pipeline' do + filter = Filter.new(page: Page.new(offset: 100, limit: 150)) + pipeline = described_class.sort_and_paginate(model, filter) + expect(pipeline).to eq([[{ '$skip' => 100 }, { '$limit' => 150 }], [], []]) + end + end + + describe 'Sort' do + context 'when sort is done on native field' do + it 'generates the relevant pipeline on the first stage' do + filter = Filter.new(sort: Sort.new([{ field: 'author:first_name', ascending: true }])) + pipeline = described_class.sort_and_paginate(model, filter) + expect(pipeline).to eq([[], [], [{ '$sort' => { 'author.first_name' => 1 } }]]) + end + end + + context 'when filtering is applied' do + context 'with filtering is on a native field' do + it 'generates the relevant pipeline on first stage' do + filter = Filter.new( + sort: Sort.new([{ field: 'title', ascending: true }]), + condition_tree: ConditionTree::Nodes::ConditionTreeLeaf.new('title', 'Equal', 'Lord of the Rings') + ) + pipeline = described_class.sort_and_paginate(model, filter) + expect(pipeline).to eq([[], [{ '$sort' => { 'title' => 1 } }], []]) + end + end + + context 'with filtering is not on a native field' do + it 'generates the relevant pipeline on third stage' do + filter = Filter.new( + sort: Sort.new([{ field: 'author:first_name', ascending: true }]), + condition_tree: ConditionTree::Nodes::ConditionTreeLeaf.new('editor', 'Equal', 'Folio') + ) + pipeline = described_class.sort_and_paginate(model, filter) + expect(pipeline).to eq([[], [], [{ '$sort' => { 'author.first_name' => 1 } }]]) + end + end + end + + context 'when sort criteria is not on a native field' do + it 'generates the relevant pipeline on second stage' do + filter = Filter.new(sort: Sort.new([{ field: 'hello', ascending: true }])) + pipeline = described_class.sort_and_paginate(model, filter) + expect(pipeline).to eq([[], [], [{ '$sort' => { 'hello' => 1 } }]]) + end + end + end + end + + describe '.list_relations_used_in_filter' do + context 'with sort' do + it 'adds relations used to sort' do + filter = Filter.new(sort: Sort.new([{ field: 'author:first_name', ascending: true }, { field: 'editor:name', ascending: false }])) + fields = described_class.list_relations_used_in_filter(filter) + expect(fields).to eq(['author', 'editor']) + end + + it 'does not add anything if soting is done on direct fields' do + filter = Filter.new(sort: Sort.new([{ field: 'title', ascending: true }])) + fields = described_class.list_relations_used_in_filter(filter) + expect(fields).to eq([]) + end + + it 'does not add anything if sort is undefined' do + filter = Filter.new + fields = described_class.list_relations_used_in_filter(filter) + expect(fields).to eq([]) + end + + it 'adds all parents and correctly format nested fields' do + filter = Filter.new(sort: Sort.new([{ field: 'author:country:name', ascending: true }])) + fields = described_class.list_relations_used_in_filter(filter) + expect(fields).to eq(['author', 'author.country']) + end + end + end + end + end + end +end From 77ff5852ec2cb76424bf03027d0e923ddc76fbeb Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 18 Feb 2025 09:56:03 +0100 Subject: [PATCH 070/104] chore: remove legacy mode --- .../lib/forest_admin_datasource_mongoid/options_parser.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb index d3ba80518..033b83b62 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb @@ -74,8 +74,6 @@ def get_manual_flatten_options(schema, options, model_name) { as_fields: as_fields, as_models: as_models } end - - def get_legacy_flatten_options(_schema, options, model_name); end end end end From adb469c803ffc57f6c6066f2f262b159595df485 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Tue, 18 Feb 2025 15:53:56 +0100 Subject: [PATCH 071/104] fix: build pipeline with relations --- .rubocop.yml | 1 + .../routes/resources/related/list_related.rb | 3 +- .../routes/resources/show.rb | 3 +- .../collection.rb | 6 +- .../parser/relation.rb | 2 +- .../utils/pipeline/lookup_generator.rb | 59 ++++++++++--------- .../utils/pipeline/reparent_generator.rb | 4 +- .../utils/schema/fields_generator.rb | 16 +++-- 8 files changed, 52 insertions(+), 42 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 356900948..7a31180b6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -258,6 +258,7 @@ Metrics/MethodLength: - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb' Metrics/BlockLength: diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/related/list_related.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/related/list_related.rb index 582c588e4..20cdcb260 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/related/list_related.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/related/list_related.rb @@ -31,7 +31,8 @@ def handle_request(args = {}) ForestAdminAgent::Utils::QueryStringParser.parse_condition_tree(@child_collection, args) ] ), - page: ForestAdminAgent::Utils::QueryStringParser.parse_pagination(args) + page: ForestAdminAgent::Utils::QueryStringParser.parse_pagination(args), + sort: ForestAdminAgent::Utils::QueryStringParser.parse_sort(@child_collection, args) ) projection = ForestAdminAgent::Utils::QueryStringParser.parse_projection_with_pks(@child_collection, args) id = Utils::Id.unpack_id(@collection, args[:params]['id'], with_key: true) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/show.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/show.rb index b9c5b8940..5b6d7eecf 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/show.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/show.rb @@ -20,8 +20,7 @@ def handle_request(args = {}) id = Utils::Id.unpack_id(@collection, args[:params]['id'], with_key: true) condition_tree = ConditionTree::ConditionTreeFactory.match_records(@collection, [id]) filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new( - condition_tree: ConditionTree::ConditionTreeFactory.intersect([condition_tree, scope]), - page: ForestAdminAgent::Utils::QueryStringParser.parse_pagination(args) + condition_tree: ConditionTree::ConditionTreeFactory.intersect([condition_tree, scope]) ) projection = ProjectionFactory.all(@collection) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index f677fb0bc..0a39bcf6f 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -31,8 +31,7 @@ def list(_caller, filter, projection) projection = projection.union(filter.condition_tree&.projection || [], filter.sort&.projection || []) pipeline = [*build_base_pipeline(filter, projection), *ProjectionGenerator.project(projection)] - add_null_values(replace_mongo_types(model.collection.aggregate(pipeline).to_a), projection) - # model.unscoped.collection.aggregate(pipeline).to_a + add_null_values(replace_mongo_types(model.unscoped.collection.aggregate(pipeline).to_a), projection) end def aggregate(_caller, filter, aggregation, limit = nil) @@ -43,10 +42,9 @@ def aggregate(_caller, filter, aggregation, limit = nil) { '$sort' => { value: -1 } } ] pipeline << { '$limit' => limit } if limit - rows = model.collection.aggregate(pipeline).to_a + rows = model.unscoped.collection.aggregate(pipeline).to_a replace_mongo_types(rows) - # Utils::QueryAggregate.new(self, aggregation, filter, limit).get end def create(caller, data) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb index 1a1baf7c6..75ea689fa 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/relation.rb @@ -7,7 +7,7 @@ def get_polymorphic_types(relation_name) ObjectSpace.each_object(Class).select { |klass| klass < Mongoid::Document }.each do |model| if model.relations.any? { |_, relation| relation.options[:as] == relation_name.to_sym } primary_key = model.fields.keys.find { |key| model.fields[key].options[:as] == :id } || :_id - types[format_model_name(model.name)] = primary_key.to_s + types[model.name.gsub('::', '__')] = primary_key.to_s end end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb index 63fbc24db..c6520b216 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb @@ -7,14 +7,14 @@ class LookupGenerator # Transform a forest admin projection into a mongo pipeline that performs the lookups # and transformations to target them def self.lookup(model, stack, projection, options) - stack.each_with_index.reduce([MongoidSchema.from_model(model)]) do |acc, (_, index)| + schema_stack = stack.each_with_index.reduce([MongoidSchema.from_model(model)]) do |acc, (_, index)| [ *acc, MongoidSchema.from_model(model).apply_stack(stack.slice(0..index + 1), skip_as_models: true) ] end - lookup_projection(nil, stack.map { |s| s[:fields] }, projection, options) + lookup_projection(nil, schema_stack.map(&:fields), projection, options) end def self.lookup_projection(current_path, schema_stack, projection, options) @@ -22,7 +22,8 @@ def self.lookup_projection(current_path, schema_stack, projection, options) fields = {} projection.relations.each do |name, relation_projection| - pipeline << lookup_relation(current_path, schema_stack, name, relation_projection, options) + pipeline.push(*lookup_relation(current_path, schema_stack, name, relation_projection, options)) + # pipeline = [*pipeline, *lookup_relation(current_path, schema_stack, name, relation_projection, options)] fields.merge!(add_fields(name, relation_projection, options)) end @@ -41,38 +42,40 @@ def self.add_fields(name, projection, options) end def self.lookup_relation(current_path, schema_stack, name, projection, options) - ObjectSpace.each_object(Class) - .select { |klass| klass < Mongoid::Document && klass.name && !klass.name.start_with?('Mongoid::') } - .to_h { |klass| [klass.name, klass] } + models = ObjectSpace + .each_object(Class) + .select { |klass| klass < Mongoid::Document && klass.name && !klass.name.start_with?('Mongoid::') } + .to_h { |klass| [klass.name, klass] } + as = current_path ? "#{current_path}.#{name}" : name last_schema = schema_stack[schema_stack.length - 1] previous_schema = schema_stack.slice(0..schema_stack.length - 1) - return [] if options[:include] && !options[:include].include?(as) - return [] if options[:exclude]&.include?(as) + return {} if options[:include] && !options[:include].include?(as) + return {} if options[:exclude]&.include?(as) # Native many to one relation - # TODO - # if (name.endsWith('__manyToOne')) { - # const foreignKeyName = name.substring(0, name.length - '__manyToOne'.length); - # const model = models[lastSchema[foreignKeyName].options.ref]; - # - # const from = model.collection.collectionName; - # const localField = currentPath ? `${currentPath}.${foreignKeyName}` : foreignKeyName; - # const foreignField = '_id'; - # - # const subSchema = MongooseSchema.fromModel(model).fields; - # - # return [ - # // Push lookup to pipeline - # { $lookup: { from, localField, foreignField, as } }, - # { $unwind: { path: `$${as}`, preserveNullAndEmptyArrays: true } }, - # - # // Recurse to get relations of relations - # ...this.lookupProjection(models, as, [...schemaStack, subSchema], subProjection, options), - # ]; - # } + identifier = '__many_to_one' + if name.end_with?(identifier) + foreign_key_name = name[0..(name.length - identifier.length - 1)] + model = models[last_schema[foreign_key_name].options[:association].class_name] + + from = model.name.gsub('::', '__') + local_field = current_path ? "#{current_path}.#{foreign_key_name}" : foreign_key_name + foreign_field = '_id' + sub_schema = MongoidSchema.from_model(model).fields + + return [ + # Push lookup to pipeline + { '$lookup' => + { 'from' => from, 'localField' => local_field, 'foreignField' => foreign_field, 'as' => as } }, + { '$unwind' => { 'path' => "$#{as}", 'preserveNullAndEmptyArrays' => true } }, + + # Recurse to get relations of relations + *lookup_projection(as, [*schema_stack, sub_schema], projection, options) + ] + end # inverse of fake relation if name == 'parent' && !previous_schema.empty? diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator.rb index 7a90e1bf0..27233c0c6 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator.rb @@ -46,7 +46,7 @@ def self.reparent_array(prefix, in_doc) { '_id' => { '$concat' => [{ '$toString' => '$_id' }, ".#{prefix}.", { '$toString' => '$index' }] }, - 'parentId' => '$_id', + 'parent_id' => '$_id', 'parent' => '$$ROOT' } ) @@ -68,7 +68,7 @@ def self.reparent_object(prefix, in_doc) prefix, { '_id' => { '$concat' => [{ '$toString' => '$_id' }, ".#{prefix}"] }, - 'parentId' => '$_id', + 'parent_id' => '$_id', 'parent' => '$$ROOT' } ) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb index cfaf8d329..8a428dc8f 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb @@ -24,10 +24,9 @@ def self.build_fields_schema(model, stack) validations: [] # get_validations(field) ) - # TODO: check BelongsTo embed - # if (field instanceof SchemaType && field.options.ref) { - # ourSchema[`${name}__manyToOne`] = this.buildManyToOne(field.options.ref, name); - # } + if field.foreign_key? && !field.association.polymorphic? + our_schema["#{name}__many_to_one"] = build_many_to_one(field) + end end return our_schema unless stack.length > 1 @@ -66,6 +65,15 @@ def self.build_virtual_primary_key is_sortable: true ) end + + def self.build_many_to_one(field) + association = field.options[:association] + ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema.new( + foreign_collection: association.klass.name.gsub('::', '__'), + foreign_key: association.foreign_key, + foreign_key_target: '_id' + ) + end end end end From 9ac356a5bee6a3e679027b89f97e46a072819dbf Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 18 Feb 2025 16:56:11 +0100 Subject: [PATCH 072/104] fix(schema): field type array --- .../utils/schema/mongoid_schema.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb index 1c50bc96a..d670f6166 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb @@ -93,9 +93,9 @@ def get_sub_schema(path) # General case: go down the tree prefix, suffix = path.split(/\.(.*)/) - is_array = false is_leaf = false child = @fields[prefix] + is_array = child.is_a?(Mongoid::Fields::Standard) && child.options[:type] == Array # Traverse relations if child.is_a?(Hash) From 9bdd5279fc88e4444aa386672c8893b4b3905d5c Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 18 Feb 2025 16:56:49 +0100 Subject: [PATCH 073/104] fix: reparent unflatten & reparent methods --- .../utils/pipeline/reparent_generator.rb | 40 +++++++------------ 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator.rb index 27233c0c6..c1dfeec58 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator.rb @@ -11,7 +11,10 @@ def self.reparent(model, stack) schema = MongoidSchema.from_model(model) stack.flat_map.with_index do |step, index| - return unflatten(step[:as_fields]) if index.zero? + # If this is the first step in the stack and there are no fields to flatten, return an empty list + next [] if index.zero? && step[:as_fields].empty? + # If this is the first step in the stack, only flatten the provided fields without reparenting + next unflatten(step[:as_fields]) if index.zero? local_schema = schema.get_sub_schema(step[:prefix]) relative_prefix = if stack[index - 1][:prefix].nil? @@ -20,16 +23,13 @@ def self.reparent(model, stack) step[:prefix][stack[index - 1][:prefix].length + 1..] end - [ - *(if local_schema.is_array - reparent_array(relative_prefix, - local_schema.is_leaf) - else - reparent_object(relative_prefix, - local_schema.is_leaf) - end), - *unflatten(step[:as_fields]) - ] + result = if local_schema.is_array + reparent_array(relative_prefix, local_schema.is_leaf) + else + reparent_object(relative_prefix, local_schema.is_leaf) + end + + [*result, *unflatten(step[:as_fields])] end end @@ -82,24 +82,14 @@ def self.reparent_object(prefix, in_doc) def self.unflatten(as_fields) return [] if as_fields.empty? - unflatten_results = [] - add_fields = as_fields.map { |f| [f.gsub('.', '@@@'), "$#{f}"] } - # DocumentDB limits the addFields stage to 30 fields. chunk_size = 30 + add_fields = as_fields.map { |f| [f.gsub('.', '@@@'), "$#{f}"] } - # TODO: refactor if/else - if add_fields.length > chunk_size - (0..(add_fields.length - 1).step(chunk_size)).each do |i| - chunk = add_fields.slice(i..i + chunk_size) - unflatten_results << { '$addFields' => chunk } - end - else - unflatten_results << { '$addFields' => add_fields.to_h } - end + # MongoDB (DocumentDB) enforces a limit of 30 fields per $addFields stage. + # We split the list into chunks of 30 to prevent errors. + unflatten_results = add_fields.each_slice(chunk_size).map { |chunk| { '$addFields' => chunk.to_h } } unflatten_results << { '$project' => as_fields.to_h { |f| [f, 0] } } - - unflatten_results end end end From aa950e72220fa2a4505008b2a3069ddd2f10dd77 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 18 Feb 2025 16:59:55 +0100 Subject: [PATCH 074/104] chore: add tests --- .../utils/group_generator_spec.rb | 81 ++++++++ .../utils/reparent_generator_spec.rb | 174 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/group_generator_spec.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/reparent_generator_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/group_generator_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/group_generator_spec.rb new file mode 100644 index 000000000..6d2363c5c --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/group_generator_spec.rb @@ -0,0 +1,81 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + module Utils + module Pipeline + include ForestAdminDatasourceToolkit::Components::Query + describe GroupGenerator do + it 'creates a pipeline for sum (w/o field nor group)' do + aggregation = Aggregation.new(operation: 'Sum', field: 'price') + + expect(described_class.group(aggregation)).to eq([ + { '$group' => { _id: nil, value: { '$sum' => '$price' } } }, + { '$project' => { '_id' => 0, 'group' => { '$literal' => {} }, 'value' => '$value' } } + ]) + end + + it 'creates a pipeline for count (w/o field nor group)' do + aggregation = Aggregation.new(operation: 'Count') + + expect(described_class.group(aggregation)).to eq([ + { '$group' => { _id: nil, value: { '$sum' => 1 } } }, + { '$project' => { '_id' => 0, 'group' => { '$literal' => {} }, 'value' => '$value' } } + ]) + end + + it 'creates a pipeline for count (w/ field)' do + aggregation = Aggregation.new(operation: 'Count', field: 'title') + + expect(described_class.group(aggregation)).to eq([ + { '$group' => { _id: nil, value: { '$sum' => { '$cond' => [{ '$ne' => ['$title', nil] }, 1, 0] } } } }, + { '$project' => { '_id' => 0, 'group' => { '$literal' => {} }, 'value' => '$value' } } + ]) + end + + it 'creates a pipeline for count (w/ groups)' do + aggregation = Aggregation.new(operation: 'Count', groups: [{ field: 'title' }]) + + expect(described_class.group(aggregation)).to eq([ + { '$group' => { _id: { 'title' => '$title' }, value: { '$sum' => 1 } } }, + { '$project' => { '_id' => 0, 'group' => { 'title' => '$_id.title' }, 'value' => '$value' } } + ]) + end + + it 'creates a pipeline for count (w/ groups by month)' do + aggregation = Aggregation.new(operation: 'Count', groups: [{ field: 'createdAt', operation: 'Month' }]) + + expect(described_class.group(aggregation)).to eq([ + { + '$group' => { + _id: { 'createdAt' => { '$dateToString' => { 'date' => '$createdAt', 'format' => '%Y-%m-01' } } }, + value: { '$sum' => 1 } + } + }, + { '$project' => { '_id' => 0, 'group' => { 'createdAt' => '$_id.createdAt' }, 'value' => '$value' } } + ]) + end + + it 'creates a pipeline for count (w/ groups by week)' do + aggregation = Aggregation.new(operation: 'Count', groups: [{ field: 'createdAt', operation: 'Week' }]) + + expect(described_class.group(aggregation)).to eq([ + { + '$group' => { + _id: { + 'createdAt' => { + '$dateToString' => { + 'date' => { '$dateTrunc' => { 'date' => '$createdAt', 'startOfWeek' => 'Monday', 'unit' => 'week' } }, + 'format' => '%Y-%m-%d' + } + } + }, + value: { '$sum' => 1 } + } + }, + { '$project' => { '_id' => 0, 'group' => { 'createdAt' => '$_id.createdAt' }, 'value' => '$value' } } + ]) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/reparent_generator_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/reparent_generator_spec.rb new file mode 100644 index 000000000..ae559feed --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/reparent_generator_spec.rb @@ -0,0 +1,174 @@ +require 'spec_helper' +require 'mongoid' + +module ForestAdminDatasourceMongoid + module Utils + module Pipeline + include ForestAdminDatasourceToolkit::Components::Query + describe ReparentGenerator do + let(:author_model) do + Class.new do + include Mongoid::Document + + field :firstname + field :lastname + end + end + + let(:edition_model) do + Class.new do + include Mongoid::Document + + field :isbn + field :year + end + end + + let(:model) do + Class.new do + include Mongoid::Document + + field :title, type: String + field :publishers, type: Array + embeds_one :author, class_name: 'Dummy::Author' + embeds_many :editions, class_name: 'Dummy::Edition' + + field :moreThen30Fields, type: Hash, default: {} + end + end + + before do + stub_const('Dummy::Author', author_model) + stub_const('Dummy::Edition', edition_model) + stub_const('Dummy::Book', model) + end + + after do + Mongoid.purge! + end + + context 'when generating pipelines' do + it 'generates an empty pipeline for the null prefix' do + stack = [{ prefix: nil, as_fields: [], as_models: [] }] + pipeline = described_class.reparent(Dummy::Book, stack) + expect(pipeline).to eq([]) + end + + it 'generates a single $replaceRoot to unnest an object' do + pipeline = described_class.reparent(Dummy::Book, [ + { prefix: nil, as_fields: [], as_models: ['author'] }, + { prefix: 'author', as_fields: [], as_models: [] } + ]) + + expect(pipeline).to eq([ + { + '$replaceRoot' => { + 'newRoot' => { + '$mergeObjects' => [ + '$author', + ConditionGenerator.tag_record_if_not_exist('author', { + '_id' => { '$concat' => [{ '$toString' => '$_id' }, '.author'] }, + 'parent_id' => '$_id', + 'parent' => '$$ROOT' + }) + ] + } + } + } + ]) + end + + it 'generates an $unwind and $replaceRoot to unnest an array of objects' do + pipeline = described_class.reparent(Dummy::Book, [ + { prefix: nil, as_fields: [], as_models: ['editions'] }, + { prefix: 'editions', as_fields: [], as_models: [] } + ]) + + expect(pipeline).to eq([ + { '$unwind' => { 'includeArrayIndex' => 'index', 'path' => '$editions' } }, + { + '$replaceRoot' => { + 'newRoot' => { + '$mergeObjects' => [ + '$editions', + ConditionGenerator.tag_record_if_not_exist('editions', { + '_id' => { '$concat' => [{ '$toString' => '$_id' }, '.editions.', { '$toString' => '$index' }] }, + 'parent_id' => '$_id', + 'parent' => '$$ROOT' + }) + ] + } + } + } + ]) + end + + it 'generates a $replaceRoot to unnest a field' do + pipeline = described_class.reparent(Dummy::Book, [ + { prefix: nil, as_fields: [], as_models: ['title'] }, + { prefix: 'title', as_fields: [], as_models: [] } + ]) + + expect(pipeline).to eq([ + { + '$replaceRoot' => { + 'newRoot' => { + '$mergeObjects' => [ + { 'content' => '$title' }, + ConditionGenerator.tag_record_if_not_exist('title', { + '_id' => { '$concat' => [{ '$toString' => '$_id' }, '.title'] }, + 'parent_id' => '$_id', + 'parent' => '$$ROOT' + }) + ] + } + } + } + ]) + end + + it 'generates an $unwind and $replaceRoot to unnest an array of primitives' do + pipeline = described_class.reparent(Dummy::Book, [ + { prefix: nil, as_fields: [], as_models: ['publishers'] }, + { prefix: 'publishers', as_fields: [], as_models: [] } + ]) + + expect(pipeline).to eq([ + { '$unwind' => { 'includeArrayIndex' => 'index', 'path' => '$publishers' } }, + { + '$replaceRoot' => { + 'newRoot' => { + '$mergeObjects' => [ + { 'content' => '$publishers' }, + ConditionGenerator.tag_record_if_not_exist('publishers', { + '_id' => { '$concat' => [{ '$toString' => '$_id' }, '.publishers.', { '$toString' => '$index' }] }, + 'parent_id' => '$_id', + 'parent' => '$$ROOT' + }) + ] + } + } + } + ]) + end + + context 'when flattening objects with more than 30 fields' do + it 'splits $addFields operations every 30 fields (DocumentDB limitation)' do + simulated_fields = (1..32).map { |i| "moreThen30Fields.field#{i}" } + + pipeline = described_class.reparent(Dummy::Book, [ + { prefix: nil, as_fields: simulated_fields, as_models: [] } + ]) + + add_fields_operations = pipeline.select { |operation| operation.key?('$addFields') } + total_fields = add_fields_operations.sum { |op| op['$addFields'].keys.length } + + expect(add_fields_operations.length).to eq(2) + expect(total_fields).to eq(32) + end + end + end + end + end + end +end From 1215bc070a6675e54a56c2c3510c32a63182d19a Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Wed, 19 Feb 2025 09:50:10 +0100 Subject: [PATCH 075/104] feat(mongo): add virtual field generatoe --- .../utils/pipeline/virtual_field_generator.rb | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/virtual_field_generator.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/virtual_field_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/virtual_field_generator.rb new file mode 100644 index 000000000..061dea8f5 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/virtual_field_generator.rb @@ -0,0 +1,75 @@ +module ForestAdminDatasourceMongoid + module Utils + module Pipeline + # When using the `asModel` options, users can request/filter on the virtual _id and parentId fields + # of children (using the generated OneToOne relation). + # + # As those fields are not written to mongo, they are injected here so that they can be used like + # any other field. + # + # This could be also be done by preprocessing the filter, and postprocessing the records, but this + # solution seemed simpler, at the cost of additional pipeline stages when making queries. + # + # Note that a projection is taken as a parameter so that only fields which are actually used are + # injected to save resources. + class VirtualFieldGenerator + # include Utils::Schema + + def self.add_virtual(_model, stack, projection) + set = {} + + projection.each do |colon_field| + field = colon_field.tr(':', '.') + is_from_one_to_one = stack[stack.length - 1][:as_models].some { |f| field.start_with?("#{f}.") } + + set[field] = get_path(field) if is_from_one_to_one + end + + set.keys.length ? [{ '$addFields' => set }] : [] + end + + def self.get_path(field) + id_identifier = '._id' + if field.end_with?(id_identifier) + suffix = field[0..(field.length - id_identifier.length)] + + return ConditionGenerator.tag_record_if_not_exist_by_value( + suffix, + { '$concat' => [{ '$toString' => '$_id' }, `.${suffix}`] } + ) + end + + parent_id_identifier = '.parent_id' + if field.end_with?(parent_id_identifier) + if field.split('.').length > 2 + # Implementing this would require us to have knowledge of the value of asModel for + # for virtual models under the current one, which the `stack` variable does not have. + + # If the expcetion causes issues we could simply return + # `$${field.substring(0, field.length - 9)}._id` but that would not work if the customer + # jumped over multiple levels of nesting. + + # As this is a use case that never happens from the UI, and that can be worked around when + # using the API, we decided to not implement it. + raise ForestAdminDatasourceToolkit::Exceptions::ForestException, + 'Fetching virtual parentId deeper than 1 level is not supported.' + end + + suffix = field[0..(field.length - parent_id_identifier.length)] + return ConditionGenerator.tag_record_if_not_exist_by_value(suffix, '$_id') + end + + content_identifier = '.content' + if field.end_with?(content_identifier) + # FIXME: we should check that this is really a leaf field because "content" can't + # really be used as a reserved word + return "$#{field[0..(field.length - content_identifier.length)]}" + end + + parent = field[0..field.rindex('.')] + ConditionGenerator.tag_record_if_not_exist_by_value(parent, "$#{field}") + end + end + end + end +end From 1494214d9274574f9f0fc55f53dc5419d0a04d9a Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 19 Feb 2025 17:35:39 +0100 Subject: [PATCH 076/104] chore: add tests --- .../utils/add_null_values.rb | 8 +- .../add_null_values_spec.rb | 80 +++++++++++++++++++ .../utils/projection_generator_spec.rb | 22 +++++ 3 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/add_null_values_spec.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/projection_generator_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/add_null_values.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/add_null_values.rb index 43f943d56..b404e0eb3 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/add_null_values.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/add_null_values.rb @@ -7,9 +7,11 @@ module AddNullValues def remove_not_exist_record(record) return nil if record.nil? || record[Pipeline::ConditionGenerator::FOREST_RECORD_DOES_NOT_EXIST] - record.each do |key, value| - if value.is_a?(Hash) && value.value?(Pipeline::ConditionGenerator::FOREST_RECORD_DOES_NOT_EXIST) - record[key] = nil + record.transform_values! do |value| + if value.is_a?(Hash) && value[Pipeline::ConditionGenerator::FOREST_RECORD_DOES_NOT_EXIST] + nil + else + value end end diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/add_null_values_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/add_null_values_spec.rb new file mode 100644 index 000000000..b1728da08 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/add_null_values_spec.rb @@ -0,0 +1,80 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + module Utils + RSpec.describe AddNullValues do + let(:include_module) { Class.new { include AddNullValues }.new } + + describe '#remove_not_exist_record' do + it 'returns nil if the record is nil' do + expect(include_module.remove_not_exist_record(nil)).to be_nil + end + + it 'removes record if FOREST_RECORD_DOES_NOT_EXIST key is present' do + record = { Pipeline::ConditionGenerator::FOREST_RECORD_DOES_NOT_EXIST => true } + expect(include_module.remove_not_exist_record(record)).to be_nil + end + + it 'returns the record unchanged if no special condition is present' do + record = { 'name' => 'value' } + expect(include_module.remove_not_exist_record(record)).to eq(record) + end + + it 'removes nested object with FOREST_RECORD_DOES_NOT_EXIST key' do + record = { 'nested' => { Pipeline::ConditionGenerator::FOREST_RECORD_DOES_NOT_EXIST => true } } + result = include_module.remove_not_exist_record(record) + expect(result['nested']).to be_nil + end + end + + describe '#add_null_values_on_record' do + let(:record) { { 'name' => 'value', 'age' => 30 } } + let(:projection) { %w[name age address] } + + it 'returns nil if the record is nil' do + expect(include_module.add_null_values_on_record(nil, projection)).to be_nil + end + + it 'adds null values for fields not present in the record' do + result = include_module.add_null_values_on_record(record, projection) + expect(result['address']).to be_nil + end + + it 'does not modify existing fields in the record' do + result = include_module.add_null_values_on_record(record, projection) + expect(result['name']).to eq('value') + end + + it 'handles nested fields and adds null values' do + nested_record = { 'person' => { 'name' => 'John' } } + projection = ['person:name', 'person:age'] + result = include_module.add_null_values_on_record(nested_record, projection) + expect(result['person']['age']).to be_nil + end + + it 'removes nested object with FOREST_RECORD_DOES_NOT_EXIST key' do + nested_record = { 'person' => { Pipeline::ConditionGenerator::FOREST_RECORD_DOES_NOT_EXIST => true } } + result = include_module.add_null_values_on_record(nested_record, ['person:name']) + expect(result['person']).to be_nil + end + end + + describe '#add_null_values' do + it 'returns an array of modified records without nil values' do + records = [{ 'name' => 'John' }, { 'name' => 'Jane' }] + projection = ['name', 'address'] + result = include_module.add_null_values(records, projection) + expect(result.length).to eq(2) + expect(result.first['address']).to be_nil + end + + it 'filters out nil records from the final result' do + records = [{ 'name' => 'John' }, nil] + projection = ['name', 'address'] + result = include_module.add_null_values(records, projection) + expect(result.length).to eq(1) + end + end + end + end +end diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/projection_generator_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/projection_generator_spec.rb new file mode 100644 index 000000000..780bfc286 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/projection_generator_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + module Utils + module Pipeline + include ForestAdminDatasourceToolkit::Components::Query + describe ProjectionGenerator do + it 'generates a $replaceRoot stage when no fields are provided' do + pipeline = described_class.project(Projection.new) + + expect(pipeline).to eq([{ '$replaceRoot' => { 'newRoot' => { '$literal' => {} } } }]) + end + + it 'generates a $project stage when fields are provided' do + pipeline = described_class.project(Projection.new(['title:_id'])) + + expect(pipeline).to eq([{ '$project' => { '_id' => false, 'title._id' => true, 'FOREST_RECORD_DOES_NOT_EXIST' => true } }]) + end + end + end + end +end From e3cd883a4ddf78ae05b28901dbfed9803a102bbb Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 20 Feb 2025 10:56:01 +0100 Subject: [PATCH 077/104] feat(mongo): add missing methods to helpers utils --- .rubocop.yml | 1 + .../utils/helpers.rb | 50 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 7a31180b6..7a9ba4e43 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -225,6 +225,7 @@ Metrics/ModuleLength: - 'packages/forest_admin_datasource_mongoid/spec/**/*' - 'packages/forest_admin_datasource_customizer/spec/**/*' - 'packages/forest_admin_rails/spec/**/*' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb' Metrics/MethodLength: CountAsOne: ['array', 'hash', 'method_call'] diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb index 2b40bfd35..0498e1dbb 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb @@ -43,9 +43,9 @@ def recursive_delete(target, path) # not sure it this method is relevant for mongoid def replace_mongo_types(data) case data - when BSON::ObjectId + when BSON::ObjectId, BSON::Decimal128 data.to_s - when Date + when Date, Time data.iso8601 when Array data.map { |item| replace_mongo_types(item) } @@ -100,6 +100,52 @@ def deep_merge(target, source) end target end + + # Compare two ids. + # This is useful to ensure we perform array operations in the right order. + # + # @example + # compareIds('a.20.a', 'a.1.b') => 1 (because 1 < 20) + # compareIds('a.0.a', 'b.1.b') => -1 (because 'a' < 'b') + def compare_ids(id_a, id_b) + parts_a = id_a.split('.') + parts_b = id_b.split('.') + length = [parts_a.length, parts_b.length].min + + (0...length).each do |i| + # if both parts are numbers, we compare them numerically + result = if parts_a[i] =~ /^\d+$/ && parts_b[i] =~ /^\d+$/ + parts_a[i].to_i <=> parts_b[i].to_i + else + # else, we compare as strings + parts_a[i] <=> parts_b[i] + end + return result unless result.zero? + end + + parts_a.length <=> parts_b.length + end + + def split_id(id) + dot_index = id.index('.') + root_id = id[0...dot_index] + path = id[(dot_index + 1)..] + + root_id = BSON::ObjectId.from_string(root_id) if BSON::ObjectId.legal?(root_id) + + [root_id, path] + end + + def group_ids_by_path(ids) + updates = Hash.new { |hash, key| hash[key] = [] } + + ids.each do |id| + root_id, path = split_id(id) + updates[path] << root_id + end + + updates + end end end end From 8b4514f6ffb0ce1b595e9b0459dc65b0e32a72e2 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 20 Feb 2025 16:03:12 +0100 Subject: [PATCH 078/104] fix: mongoid schema flatten mode --- .../lib/forest_admin_datasource_mongoid/options_parser.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb index 033b83b62..c7ab754dd 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/options_parser.rb @@ -22,8 +22,8 @@ def get_auto_flatten_options(schema) # Split on all arrays of objects and arrays of references. as_models = schema.list_paths_matching(proc do |field, path_schema| path_schema.is_array && - # FIX SCHEMA NODE OPTIONS REF - (!path_schema.is_leaf || path_schema.schema_node&.options&.ref) && + (!path_schema.is_leaf || + (path_schema.schema_node&.options.is_a?(Hash) && path_schema.schema_node.options[:ref])) && forbidden_paths.none? { |p| field == p || field.start_with?("#{p}.") } end).sort From ab6cd8690105ce30348e01c04c18f076212885c6 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 21 Feb 2025 14:26:16 +0100 Subject: [PATCH 079/104] chore: add test on helpers --- .../helpers_spec.rb | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/helpers_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/helpers_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/helpers_spec.rb new file mode 100644 index 000000000..f21302985 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/helpers_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + module Utils + RSpec.describe Helpers do + let(:helpers_module) { Class.new { include Helpers }.new } + + it 'replace dots by underscores' do + expect(helpers_module.escape('a.b.c')).to eq('a_b_c') + end + + it 'recursivelies set a value in a plain object' do + obj = {} + helpers_module.recursive_set(obj, 'a.b.c', 1) + helpers_module.recursive_set(obj, 'a.b.d', 2) + expect(obj).to eq({ 'a' => { 'b' => { 'c' => 1, 'd' => 2 } } }) + end + + it 'compares using both ordinal and lexicographical order' do + expect(helpers_module.compare_ids('a', 'a')).to eq(0) + + expect(helpers_module.compare_ids('a', 'b')).to be.negative? + expect(helpers_module.compare_ids('b', 'a')).to be.positive? + + expect(helpers_module.compare_ids('a', 'a.b')).to be.negative? + expect(helpers_module.compare_ids('a.b', 'a')).to be.positive? + + expect(helpers_module.compare_ids('a.2.b', 'a.10.a')).to be.negative? + expect(helpers_module.compare_ids('a.10.a', 'a.2.b')).to be.positive? + end + + it 'splitId should separate rootId and path' do + expect(helpers_module.split_id('a.b.c')).to eq(['a', 'b.c']) + + expect(helpers_module.split_id('5a934e000102030405000000.c')).to eq([BSON::ObjectId('5a934e000102030405000000'), 'c']) + end + + it 'regroup rootIds by path' do + groups = helpers_module.group_ids_by_path(['a.b.c', 'b.b.c', 'a.b.d']) + + expect(groups).to eq({ 'b.c' => ['a', 'b'], 'b.d' => ['a'] }) + end + + it 'replaceMongoTypes should replace objectids, decimal128 and dates by strings' do + record = helpers_module.replace_mongo_types({ + nested: [ + { + _id: BSON::ObjectId.from_string('5a934e000102030405000000'), + date: Time.new(1985, 10, 26, 1, 22, 0, '-08:00'), + price: BSON::Decimal128.new('42') + } + ] + }) + + expect(record).to eq({ nested: [{ _id: '5a934e000102030405000000', date: '1985-10-26T01:22:00-08:00', price: '42' }] }) + end + end + end +end From a23cc846d5994eb832e9f5d9d63ca29b1311f874 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 24 Feb 2025 10:13:23 +0100 Subject: [PATCH 080/104] fix: test add_null_values --- .../add_null_values_spec.rb | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/add_null_values_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/add_null_values_spec.rb index b1728da08..aeb7c9433 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/add_null_values_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/add_null_values_spec.rb @@ -3,26 +3,26 @@ module ForestAdminDatasourceMongoid module Utils RSpec.describe AddNullValues do - let(:include_module) { Class.new { include AddNullValues }.new } + let(:add_null_values_module) { Class.new { include AddNullValues }.new } describe '#remove_not_exist_record' do it 'returns nil if the record is nil' do - expect(include_module.remove_not_exist_record(nil)).to be_nil + expect(add_null_values_module.remove_not_exist_record(nil)).to be_nil end it 'removes record if FOREST_RECORD_DOES_NOT_EXIST key is present' do record = { Pipeline::ConditionGenerator::FOREST_RECORD_DOES_NOT_EXIST => true } - expect(include_module.remove_not_exist_record(record)).to be_nil + expect(add_null_values_module.remove_not_exist_record(record)).to be_nil end it 'returns the record unchanged if no special condition is present' do record = { 'name' => 'value' } - expect(include_module.remove_not_exist_record(record)).to eq(record) + expect(add_null_values_module.remove_not_exist_record(record)).to eq(record) end it 'removes nested object with FOREST_RECORD_DOES_NOT_EXIST key' do record = { 'nested' => { Pipeline::ConditionGenerator::FOREST_RECORD_DOES_NOT_EXIST => true } } - result = include_module.remove_not_exist_record(record) + result = add_null_values_module.remove_not_exist_record(record) expect(result['nested']).to be_nil end end @@ -32,29 +32,29 @@ module Utils let(:projection) { %w[name age address] } it 'returns nil if the record is nil' do - expect(include_module.add_null_values_on_record(nil, projection)).to be_nil + expect(add_null_values_module.add_null_values_on_record(nil, projection)).to be_nil end it 'adds null values for fields not present in the record' do - result = include_module.add_null_values_on_record(record, projection) + result = add_null_values_module.add_null_values_on_record(record, projection) expect(result['address']).to be_nil end it 'does not modify existing fields in the record' do - result = include_module.add_null_values_on_record(record, projection) + result = add_null_values_module.add_null_values_on_record(record, projection) expect(result['name']).to eq('value') end it 'handles nested fields and adds null values' do nested_record = { 'person' => { 'name' => 'John' } } projection = ['person:name', 'person:age'] - result = include_module.add_null_values_on_record(nested_record, projection) + result = add_null_values_module.add_null_values_on_record(nested_record, projection) expect(result['person']['age']).to be_nil end it 'removes nested object with FOREST_RECORD_DOES_NOT_EXIST key' do nested_record = { 'person' => { Pipeline::ConditionGenerator::FOREST_RECORD_DOES_NOT_EXIST => true } } - result = include_module.add_null_values_on_record(nested_record, ['person:name']) + result = add_null_values_module.add_null_values_on_record(nested_record, ['person:name']) expect(result['person']).to be_nil end end @@ -63,7 +63,7 @@ module Utils it 'returns an array of modified records without nil values' do records = [{ 'name' => 'John' }, { 'name' => 'Jane' }] projection = ['name', 'address'] - result = include_module.add_null_values(records, projection) + result = add_null_values_module.add_null_values(records, projection) expect(result.length).to eq(2) expect(result.first['address']).to be_nil end @@ -71,7 +71,7 @@ module Utils it 'filters out nil records from the final result' do records = [{ 'name' => 'John' }, nil] projection = ['name', 'address'] - result = include_module.add_null_values(records, projection) + result = add_null_values_module.add_null_values(records, projection) expect(result.length).to eq(1) end end From 5723380033877c038e4e8ccbc595286f5ecc6f4f Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 24 Feb 2025 10:47:11 +0100 Subject: [PATCH 081/104] refactor: tests tree --- .../utils/{ => pipeline}/filter_generator_spec.rb | 0 .../utils/{ => pipeline}/group_generator_spec.rb | 0 .../utils/{ => pipeline}/projection_generator_spec.rb | 0 .../utils/{ => pipeline}/reparent_generator_spec.rb | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/{ => pipeline}/filter_generator_spec.rb (100%) rename packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/{ => pipeline}/group_generator_spec.rb (100%) rename packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/{ => pipeline}/projection_generator_spec.rb (100%) rename packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/{ => pipeline}/reparent_generator_spec.rb (100%) diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/filter_generator_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator_spec.rb similarity index 100% rename from packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/filter_generator_spec.rb rename to packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/group_generator_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/group_generator_spec.rb similarity index 100% rename from packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/group_generator_spec.rb rename to packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/group_generator_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/projection_generator_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/projection_generator_spec.rb similarity index 100% rename from packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/projection_generator_spec.rb rename to packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/projection_generator_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/reparent_generator_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator_spec.rb similarity index 100% rename from packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/reparent_generator_spec.rb rename to packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator_spec.rb From f31349a054da16731e13579fb9dd4c71b06ee925 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 25 Feb 2025 11:09:56 +0100 Subject: [PATCH 082/104] fix: virtual field & add test --- .../utils/pipeline/virtual_field_generator.rb | 21 ++-- .../pipeline/virtual_field_generator_spec.rb | 96 +++++++++++++++++++ 2 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/virtual_field_generator_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/virtual_field_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/virtual_field_generator.rb index 061dea8f5..20037b773 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/virtual_field_generator.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/virtual_field_generator.rb @@ -13,34 +13,34 @@ module Pipeline # Note that a projection is taken as a parameter so that only fields which are actually used are # injected to save resources. class VirtualFieldGenerator - # include Utils::Schema - def self.add_virtual(_model, stack, projection) set = {} projection.each do |colon_field| field = colon_field.tr(':', '.') - is_from_one_to_one = stack[stack.length - 1][:as_models].some { |f| field.start_with?("#{f}.") } + is_from_one_to_one = stack.last[:as_models].any? { |f| field.start_with?("#{f}.") } set[field] = get_path(field) if is_from_one_to_one end - set.keys.length ? [{ '$addFields' => set }] : [] + set.keys.empty? ? [] : [{ '$addFields' => set }] end def self.get_path(field) id_identifier = '._id' if field.end_with?(id_identifier) - suffix = field[0..(field.length - id_identifier.length)] + # ... dots to exclude the last character (ex: 'author.' => 'author') + suffix = field[0...(field.length - id_identifier.length)] return ConditionGenerator.tag_record_if_not_exist_by_value( suffix, - { '$concat' => [{ '$toString' => '$_id' }, `.${suffix}`] } + { '$concat' => [{ '$toString' => '$_id' }, (suffix.empty? ? '' : ".#{suffix}")] } ) end parent_id_identifier = '.parent_id' if field.end_with?(parent_id_identifier) + if field.split('.').length > 2 # Implementing this would require us to have knowledge of the value of asModel for # for virtual models under the current one, which the `stack` variable does not have. @@ -52,10 +52,10 @@ def self.get_path(field) # As this is a use case that never happens from the UI, and that can be worked around when # using the API, we decided to not implement it. raise ForestAdminDatasourceToolkit::Exceptions::ForestException, - 'Fetching virtual parentId deeper than 1 level is not supported.' + 'Fetching virtual parent_id deeper than 1 level is not supported.' end + suffix = field[0...(field.length - parent_id_identifier.length)] - suffix = field[0..(field.length - parent_id_identifier.length)] return ConditionGenerator.tag_record_if_not_exist_by_value(suffix, '$_id') end @@ -63,10 +63,13 @@ def self.get_path(field) if field.end_with?(content_identifier) # FIXME: we should check that this is really a leaf field because "content" can't # really be used as a reserved word - return "$#{field[0..(field.length - content_identifier.length)]}" + + return "$#{field[0...(field.length - content_identifier.length)]}" end parent = field[0..field.rindex('.')] + parent = parent.gsub(/\.+$/, '') # Remove trailing dots + ConditionGenerator.tag_record_if_not_exist_by_value(parent, "$#{field}") end end diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/virtual_field_generator_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/virtual_field_generator_spec.rb new file mode 100644 index 000000000..1e86e6c81 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/virtual_field_generator_spec.rb @@ -0,0 +1,96 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + module Utils + module Pipeline + describe VirtualFieldGenerator do + it 'does not add fields that are not within the projection' do + projection = [] + stack = [{ prefix: nil, as_fields: [], as_models: ['author'] }] + pipeline = described_class.add_virtual(Post, stack, projection) + + expect(pipeline).to eq([]) + end + + it 'adds virtual fields fake many to one' do + projection = ['author:_id', 'author:parent_id'] + stack = [{ prefix: nil, as_fields: [], as_models: ['author'] }] + pipeline = described_class.add_virtual(Post, stack, projection) + + expect(pipeline).to eq([ + { + '$addFields' => { + 'author._id' => ConditionGenerator.tag_record_if_not_exist_by_value('author', { + '$concat' => [{ '$toString' => '$_id' }, '.author'] + }), + 'author.parent_id' => ConditionGenerator.tag_record_if_not_exist_by_value('author', '$_id') + } + } + ]) + end + + it 'adds virtual fields on boxed many to one' do + projection = ['title:_id', 'title:parent_id', 'title:content'] + stack = [{ prefix: nil, as_fields: [], as_models: ['title'] }] + pipeline = described_class.add_virtual(Post, stack, projection) + + expect(pipeline).to eq([ + { + '$addFields' => { + 'title._id' => ConditionGenerator.tag_record_if_not_exist_by_value('title', { + '$concat' => [{ '$toString' => '$_id' }, '.title'] + }), + 'title.parent_id' => ConditionGenerator.tag_record_if_not_exist_by_value('title', '$_id'), + 'title.content' => '$title' + } + } + ]) + end + + it 'adds nested dependencies besides for parent_id (for server-side queries only)' do + projection = ['author:country:_id'] + stack = [{ prefix: nil, as_fields: [], as_models: ['author'] }] + pipeline = described_class.add_virtual(Post, stack, projection) + + expect(pipeline).to eq([ + { + '$addFields' => { + 'author.country._id' => ConditionGenerator.tag_record_if_not_exist_by_value( + 'author.country', + { + '$concat' => [{ '$toString' => '$_id' }, '.author.country'] + } + ) + } + } + ]) + end + + it 'adds nested dependencies' do + projection = ['author:country:name'] + stack = [{ prefix: nil, as_fields: [], as_models: ['author'] }] + pipeline = described_class.add_virtual(Post, stack, projection) + + expect(pipeline).to eq([ + { + '$addFields' => { + 'author.country.name' => ConditionGenerator.tag_record_if_not_exist_by_value( + 'author.country', + '$author.country.name' + ) + } + } + ]) + end + + it 'crashes on nested parent_id (for server-side queries only)' do + projection = ['author:country:parent_id'] + stack = [{ prefix: nil, as_fields: [], as_models: ['author'] }] + expect do + described_class.add_virtual(Post, stack, projection) + end.to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, '🌳🌳🌳 Fetching virtual parent_id deeper than 1 level is not supported.') + end + end + end + end +end From 3c733f008edddb553dbc93a312b48b51354ea106 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 25 Feb 2025 11:10:22 +0100 Subject: [PATCH 083/104] chore: add first tests on lookupGenerator --- .../utils/pipeline/lookup_generator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb index c6520b216..57c8ba2f0 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb @@ -20,7 +20,7 @@ def self.lookup(model, stack, projection, options) def self.lookup_projection(current_path, schema_stack, projection, options) pipeline = [] fields = {} - + puts projection.relations.inspect projection.relations.each do |name, relation_projection| pipeline.push(*lookup_relation(current_path, schema_stack, name, relation_projection, options)) # pipeline = [*pipeline, *lookup_relation(current_path, schema_stack, name, relation_projection, options)] From d1eb19822c17777218adecbeea7daf7b50514103 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 25 Feb 2025 15:41:28 +0100 Subject: [PATCH 084/104] chore: add first test on lookupgenerator --- .../utils/pipeline/lookup_generator.rb | 1 - .../utils/pipeline/lookup_generator_spec.rb | 58 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb index 57c8ba2f0..524ff4b90 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb @@ -20,7 +20,6 @@ def self.lookup(model, stack, projection, options) def self.lookup_projection(current_path, schema_stack, projection, options) pipeline = [] fields = {} - puts projection.relations.inspect projection.relations.each do |name, relation_projection| pipeline.push(*lookup_relation(current_path, schema_stack, name, relation_projection, options)) # pipeline = [*pipeline, *lookup_relation(current_path, schema_stack, name, relation_projection, options)] diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator_spec.rb new file mode 100644 index 000000000..ca98d5531 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + module Utils + module Pipeline + include ForestAdminDatasourceToolkit::Components::Query + describe LookupGenerator do + let(:stack) { [{ prefix: nil, as_fields: [], as_models: [] }] } + + describe 'with the root collection' do + it 'crashes when non-existent relations are asked for' do + projection = Projection.new(['myAuthor:firstname']) + expect do + described_class.lookup(Post, stack, projection, {}) + end.to raise_error(ForestAdminDatasourceToolkit::Exceptions::ForestException, "🌳🌳🌳 Unexpected relation: 'myAuthor'") + end + + it 'does nothing with projection that only contains columns' do + projection = Projection.new(['title']) + pipeline = described_class.lookup(Post, stack, projection, {}) + + expect(pipeline).to eq([]) + end + + it 'does nothing with projection that only contains fake relations' do + projection = Projection.new(['label:name']) + pipeline = described_class.lookup(Band, stack, projection, {}) + + expect(pipeline).to eq([]) + end + + # TODO: when relations are totally implemented + # it 'should load the post (relation)' do + # projection = Projection.new(['post__many_to_one:title']) + # pipeline = LookupGenerator.lookup(Comment, stack, projection, {}) + # + # expect(pipeline).to eq([ + # { + # '$lookup' => { + # 'from' => 'posts', + # 'localField' => 'post_id', + # 'foreignField' => '_id', + # 'as' => 'post__manyToOne' + # } + # }, + # { + # '$unwind' => { + # 'path' => '$post__manyToOne', + # 'preserveNullAndEmptyArrays' => true + # } + # } + # ]) + # end + end + end + end + end +end From 896b70deeaa3fd4679bb26c473fa96fdf26f5a3d Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 25 Feb 2025 17:23:27 +0100 Subject: [PATCH 085/104] chore: add test on condition --- .../pipeline/condition_generator_spec.rb | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/condition_generator_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/condition_generator_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/condition_generator_spec.rb new file mode 100644 index 000000000..80498b34e --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/condition_generator_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + module Utils + module Pipeline + describe ConditionGenerator do + describe '.tag_record_if_not_exist' do + it 'returns a $cond with FOREST_RECORD_DOES_NOT_EXIST for missing field' do + result = described_class.tag_record_if_not_exist('field', 'then_value') + + expect(result).to eq({ + '$cond' => { + 'if' => { + '$and' => [ + { '$ne' => [{ '$type' => '$field' }, 'missing'] }, + { '$ne' => ['$field', nil] } + ] + }, + 'then' => 'then_value', + 'else' => { 'FOREST_RECORD_DOES_NOT_EXIST' => true } + } + }) + end + end + + describe '.tag_record_if_not_exist_by_value' do + it 'returns a $cond with FOREST_RECORD_DOES_NOT_EXIST as value' do + result = described_class.tag_record_if_not_exist_by_value('field', 'then_value') + + expect(result).to eq({ + '$cond' => { + 'if' => { + '$and' => [ + { '$ne' => [{ '$type' => '$field' }, 'missing'] }, + { '$ne' => ['$field', nil] } + ] + }, + 'then' => 'then_value', + 'else' => 'FOREST_RECORD_DOES_NOT_EXIST' + } + }) + end + end + + describe '.if_missing' do + it 'returns then_expr if field exists and is not nil' do + result = described_class.if_missing('field', 'then_value', 'else_value') + + expect(result).to eq({ + '$cond' => { + 'if' => { + '$and' => [ + { '$ne' => [{ '$type' => '$field' }, 'missing'] }, + { '$ne' => ['$field', nil] } + ] + }, + 'then' => 'then_value', + 'else' => 'else_value' + } + }) + end + end + end + end + end +end From a8647bf7b14c6fed549d0caaecfd12f1e27c0f76 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Wed, 26 Feb 2025 16:03:54 +0100 Subject: [PATCH 086/104] fix: field generator --- .../utils/schema/fields_generator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb index 8a428dc8f..6b52f53df 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb @@ -24,7 +24,7 @@ def self.build_fields_schema(model, stack) validations: [] # get_validations(field) ) - if field.foreign_key? && !field.association.polymorphic? + if field.foreign_key? && field.type != Array && !field.association.polymorphic? our_schema["#{name}__many_to_one"] = build_many_to_one(field) end end From 6f6c9d9290ce4860f4432a0094cf8f57cf7eb36b Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 26 Feb 2025 16:13:25 +0100 Subject: [PATCH 087/104] fix: test on collection & datasource --- .../collection_spec.rb | 291 ++++++++++-------- .../datasource_spec.rb | 7 +- 2 files changed, 164 insertions(+), 134 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb index 448041ef9..800f3529a 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb @@ -19,146 +19,171 @@ module ForestAdminDatasourceMongoid expect(collection_post.name).to eq('Post') end - it 'add all fields of model to the collection' do - expect(collection_post.schema[:fields].keys).to include('_id', - 'created_at', - 'updated_at', - 'title', - 'body', - 'tag_ids', - 'comments', - 'author', - 'tags') + it 'builds a collection with the right datasource and schema' do + expect(collection_post.datasource).to eq(datasource) + schema = collection_post.schema + + expect(schema[:actions]).to eq({}) + expect(schema[:charts]).to eq([]) + expect(schema[:countable]).to be(true) + expect(schema[:searchable]).to be(false) + expect(schema[:segments]).to eq([]) + expect(schema[:fields].keys).to include('_id', 'created_at', 'updated_at', 'title', 'body', 'rating', 'tag_ids', 'tag_ids__many_to_one') end - describe 'fetch_associations' do - it 'add all relation of model to the collection' do - expect(collection_post.schema[:fields].keys).to include('comments', 'author', 'tags') + it 'escapes collection names' do + model = Class.new do + include Mongoid::Document + field :content, type: String end - it 'defines the correct association types' do - expect(collection_post.schema[:fields]['comments']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema) - expect(collection_post.schema[:fields]['author']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToOneSchema) - expect(collection_post.schema[:fields]['tags']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema) - expect(collection_user.schema[:fields]['item']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicManyToOneSchema) - expect(collection_departure.schema[:fields]['user']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicOneToOneSchema) - expect(collection_comment.schema[:fields]['post']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) - end - - context 'when the relation is embedded' do - it 'add a single field for an embedded many relation' do - expect(collection_user.schema[:fields]['addresses']).to be_a(ForestAdminDatasourceToolkit::Schema::ColumnSchema) - end - - it 'add single field with a composite type' do - expect(collection_band.schema[:fields].keys).to include('label') - expect(collection_band.schema[:fields]['label'].column_type).to eq( - { - '_id' => 'String', - 'name' => 'String', - 'section' => { '_id' => 'String', 'content' => 'String', 'body' => 'String' } - } - ) - end - end - end - - it 'serializes the schema to JSON' do - json_schema = collection_post.schema.to_json - expect { JSON.parse(json_schema) }.not_to raise_error - expect(JSON.parse(json_schema)['fields'].keys).to include('title', 'body', 'comments') - end - - describe '#list' do - it 'returns a serialized list of records' do - post = Post.new(title: 'Test', body: 'Body') - query_result = [post] - query = instance_double(Utils::Query, get: query_result) - allow(Utils::Query).to receive_messages(new: query) - - result = collection_post.list(nil, Filter.new, Projection.new(%w[_id title body])) - - expect(result).to eq([{ '_id' => post._id, 'title' => post.title, 'body' => post.body }]) - end - - it 'returns a serialized list of records with many to one relation' do - post = Post.new(title: 'Test', body: 'Body') - comment = Comment.new(name: 'foo', message: 'lorem ipsum', post: post) - query_result = [comment] - query = instance_double(Utils::Query, get: query_result) - allow(Utils::Query).to receive_messages(new: query) - - result = collection_comment.list(nil, Filter.new, Projection.new(%w[_id name message post:title])) - - expect(result).to eq( - [ - { - '_id' => comment._id, - 'message' => comment.message, - 'name' => comment.name, - 'post' => { 'title' => post.title } - } - ] - ) - end - end - - describe '#aggregate' do - it 'call QueryAggregate and return an aggregate result' do - Post.new(title: 'Test', body: 'Body') - query = instance_double(Utils::QueryAggregate, get: [{ 'value' => 1 }]) - allow(Utils::QueryAggregate).to receive_messages(new: query) - - filter = Filter.new - aggregation = Aggregation.new(operation: 'Count') - result = collection_post.aggregate(nil, filter, aggregation, 10) - - expect(Utils::QueryAggregate).to( - have_received(:new) do |collection, aggregation_request, filter_request, limit| - expect(collection).to eq(collection_post) - expect(filter).to eq(filter_request) - expect(aggregation).to eq(aggregation_request) - expect(limit).to eq(10) - end - ) - expect(result).to eq([{ 'value' => 1 }]) - end - end + stub_const('Dummy::MyModel', model) - describe '#create' do - it 'call create method of model and return object with full projection' do - post = Post.new(title: 'Test', body: 'Body') - model = class_double(Post, create: post) - allow(collection_post).to receive(:model).and_return(model) + collection = described_class.new(datasource, Dummy::MyModel, [{ prefix: nil, as_fields: [], as_models: [] }]) - result = collection_post.create(nil, { title: 'Test', body: 'Body' }) - - expect(result).to eq({ '_id' => post._id, 'title' => post.title, 'body' => post.body, 'author' => nil }) - end + expect(collection.name).to eq('Dummy__MyModel') end - describe '#update' do - it 'call update method of object' do - model = instance_double(Post, update: true) - query = instance_double(Utils::Query, build: [model]) - allow(Utils::Query).to receive_messages(new: query) - - collection_post.update(nil, Filter.new, { title: 'new title' }) - expect(model).to have_received(:update) do |data| - expect(data).to eq({ title: 'new title' }) - end - end - end - - describe '#delete' do - it 'call destroy method of object' do - model = instance_double(Post, destroy: true) - query = instance_double(Utils::Query, build: [model]) - allow(Utils::Query).to receive_messages(new: query) - - collection_post.delete(nil, Filter.new) - expect(model).to have_received(:destroy) - end - end + # it 'add all fields of model to the collection' do + # expect(collection_post.schema[:fields].keys).to include('_id', + # 'created_at', + # 'updated_at', + # 'title', + # 'body', + # 'tag_ids', + # 'comments', + # 'author', + # 'tags') + # end + # + # describe 'fetch_associations' do + # it 'add all relation of model to the collection' do + # expect(collection_post.schema[:fields].keys).to include('comments', 'author', 'tags') + # end + # + # it 'defines the correct association types' do + # expect(collection_post.schema[:fields]['comments']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema) + # expect(collection_post.schema[:fields]['author']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToOneSchema) + # expect(collection_post.schema[:fields]['tags']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema) + # expect(collection_user.schema[:fields]['item']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicManyToOneSchema) + # expect(collection_departure.schema[:fields]['user']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicOneToOneSchema) + # expect(collection_comment.schema[:fields]['post']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + # end + # + # context 'when the relation is embedded' do + # it 'add a single field for an embedded many relation' do + # expect(collection_user.schema[:fields]['addresses']).to be_a(ForestAdminDatasourceToolkit::Schema::ColumnSchema) + # end + # + # it 'add single field with a composite type' do + # expect(collection_band.schema[:fields].keys).to include('label') + # expect(collection_band.schema[:fields]['label'].column_type).to eq( + # { + # '_id' => 'String', + # 'name' => 'String', + # 'section' => { '_id' => 'String', 'content' => 'String', 'body' => 'String' } + # } + # ) + # end + # end + # end + # + # it 'serializes the schema to JSON' do + # json_schema = collection_post.schema.to_json + # expect { JSON.parse(json_schema) }.not_to raise_error + # expect(JSON.parse(json_schema)['fields'].keys).to include('title', 'body', 'comments') + # end + # + # describe '#list' do + # it 'returns a serialized list of records' do + # post = Post.new(title: 'Test', body: 'Body') + # query_result = [post] + # query = instance_double(Utils::Query, get: query_result) + # allow(Utils::Query).to receive_messages(new: query) + # + # result = collection_post.list(nil, Filter.new, Projection.new(%w[_id title body])) + # + # expect(result).to eq([{ '_id' => post._id, 'title' => post.title, 'body' => post.body }]) + # end + # + # it 'returns a serialized list of records with many to one relation' do + # post = Post.new(title: 'Test', body: 'Body') + # comment = Comment.new(name: 'foo', message: 'lorem ipsum', post: post) + # query_result = [comment] + # query = instance_double(Utils::Query, get: query_result) + # allow(Utils::Query).to receive_messages(new: query) + # + # result = collection_comment.list(nil, Filter.new, Projection.new(%w[_id name message post:title])) + # + # expect(result).to eq( + # [ + # { + # '_id' => comment._id, + # 'message' => comment.message, + # 'name' => comment.name, + # 'post' => { 'title' => post.title } + # } + # ] + # ) + # end + # end + # + # describe '#aggregate' do + # it 'call QueryAggregate and return an aggregate result' do + # Post.new(title: 'Test', body: 'Body') + # query = instance_double(Utils::QueryAggregate, get: [{ 'value' => 1 }]) + # allow(Utils::QueryAggregate).to receive_messages(new: query) + # + # filter = Filter.new + # aggregation = Aggregation.new(operation: 'Count') + # result = collection_post.aggregate(nil, filter, aggregation, 10) + # + # expect(Utils::QueryAggregate).to( + # have_received(:new) do |collection, aggregation_request, filter_request, limit| + # expect(collection).to eq(collection_post) + # expect(filter).to eq(filter_request) + # expect(aggregation).to eq(aggregation_request) + # expect(limit).to eq(10) + # end + # ) + # expect(result).to eq([{ 'value' => 1 }]) + # end + # end + # + # describe '#create' do + # it 'call create method of model and return object with full projection' do + # post = Post.new(title: 'Test', body: 'Body') + # model = class_double(Post, create: post) + # allow(collection_post).to receive(:model).and_return(model) + # + # result = collection_post.create(nil, { title: 'Test', body: 'Body' }) + # + # expect(result).to eq({ '_id' => post._id, 'title' => post.title, 'body' => post.body, 'author' => nil }) + # end + # end + # + # describe '#update' do + # it 'call update method of object' do + # model = instance_double(Post, update: true) + # query = instance_double(Utils::Query, build: [model]) + # allow(Utils::Query).to receive_messages(new: query) + # + # collection_post.update(nil, Filter.new, { title: 'new title' }) + # expect(model).to have_received(:update) do |data| + # expect(data).to eq({ title: 'new title' }) + # end + # end + # end + # + # describe '#delete' do + # it 'call destroy method of object' do + # model = instance_double(Post, destroy: true) + # query = instance_double(Utils::Query, build: [model]) + # allow(Utils::Query).to receive_messages(new: query) + # + # collection_post.delete(nil, Filter.new) + # expect(model).to have_received(:destroy) + # end + # end end end diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb index c701f968c..90207c66c 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb @@ -5,8 +5,13 @@ module ForestAdminDatasourceMongoid describe Datasource do let(:datasource) { described_class.new } + before do + logger = instance_double(Logger, log: nil) + allow(ForestAdminAgent::Facades::Container).to receive(:logger).and_return(logger) + end + it 'adds collections to the datasource' do - expected = %w[Band Author Tag Comment Departure Post User Team] + expected = %w[Band Author Tag Comment Departure Post User Team addresses_User] expect(datasource.collections.keys).to match_array(expected) end From f7b8052c7403a23942e44ec3dd6d94141c9ed7a1 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 26 Feb 2025 16:14:20 +0100 Subject: [PATCH 088/104] chore: set config for dummy rails >8.1 --- .../spec/dummy/config/application.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/config/application.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/config/application.rb index b53654389..fcec162b5 100644 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/config/application.rb +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/config/application.rb @@ -40,5 +40,7 @@ class Application < Rails::Application # Don't generate system test files. config.generators.system_tests = nil + + config.active_support.to_time_preserves_timezone = :zone end end From 0a97f47ab8b42c89bf750cb32b5b0b6ff44a4900 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 27 Feb 2025 10:39:36 +0100 Subject: [PATCH 089/104] fix: field handle hash --- .rubocop.yml | 1 + .../forest_admin_datasource_mongoid/parser/column.rb | 6 +++++- .../forest_admin_datasource_mongoid/utils/helpers.rb | 2 +- .../utils/schema/fields_generator.rb | 10 ++++++++-- .../utils/schema/mongoid_schema.rb | 1 - 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 7a9ba4e43..69b8d44b6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -257,6 +257,7 @@ Metrics/MethodLength: - 'packages/forest_admin_datasource_customizer/lib/forest_admin_datasource_customizer/decorators/action/form_factory.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb' + - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator.rb' - 'packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb' diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb index 12bc8e32f..58b941e53 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb @@ -38,13 +38,17 @@ def get_column_type(column) end return column.is_a?(Mongoid::Association::Embedded::EmbedsMany) ? [type] : type + elsif column.is_a? Hash + return column.reduce({}) do |memo, (name, sub_column)| + memo.merge({ name => get_column_type(sub_column) }) + end end 'String' end def get_default_value(column) - if column.options.key?(:default) + if column.respond_to?(:options) && column.options.key?(:default) default = column.options[:default] return default.respond_to?(:call) ? default.call : default diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb index 0498e1dbb..5e0d403fe 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/helpers.rb @@ -30,7 +30,7 @@ def recursive_delete(target, path) if index.nil? target.delete(path) else - prefix = path[0, index] + prefix = path[0..index - 1] suffix = path[index + 1..] if target.is_a?(Hash) && target.key?(prefix) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb index 6b52f53df..7134ed273 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb @@ -13,18 +13,24 @@ def self.build_fields_schema(model, stack) child_schema.fields.each do |name, field| next unless name != 'parent' + default_value = if field.respond_to?(:object_id_field?) && field.object_id_field? + nil + else + get_default_value(field) + end + our_schema[name] = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( column_type: get_column_type(field), filter_operators: operators_for_column_type(get_column_type(field)), is_primary_key: name == '_id', is_read_only: false, is_sortable: get_column_type(field) != 'Json', - default_value: field.object_id_field? ? nil : get_default_value(field), + default_value: default_value, enum_values: [], validations: [] # get_validations(field) ) - if field.foreign_key? && field.type != Array && !field.association.polymorphic? + if !field.is_a?(Hash) && field.foreign_key? && field.type != Array && !field.association.polymorphic? our_schema["#{name}__many_to_one"] = build_many_to_one(field) end end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb index d670f6166..7ca58b2b2 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb @@ -43,7 +43,6 @@ def self.build_fields(schema_fields, level = 0) targets = {} schema_fields.each do |name, field| - # start_with?("$") useless ?? next if name.start_with?('$') || name.include?('__') || (name == '_id' && level.positive?) if VersionManager.sub_document?(field) From 29ccae485422dfb7b187f36ee1bc133a46ed0b3e Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Thu, 27 Feb 2025 15:46:24 +0100 Subject: [PATCH 090/104] test: add tests on lookup generator --- .../utils/pipeline/lookup_generator.rb | 10 +- .../spec/dummy/app/models/address.rb | 1 + .../spec/dummy/app/models/author.rb | 1 + .../spec/dummy/app/models/co_author.rb | 8 + .../spec/dummy/app/models/meta.rb | 6 + .../spec/dummy/app/models/post.rb | 3 + .../spec/dummy/app/models/user.rb | 1 + .../utils/pipeline/lookup_generator_spec.rb | 333 ++++++++++++++++-- 8 files changed, 336 insertions(+), 27 deletions(-) create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/models/co_author.rb create mode 100644 packages/forest_admin_datasource_mongoid/spec/dummy/app/models/meta.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb index 524ff4b90..47b6108b9 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator.rb @@ -26,6 +26,8 @@ def self.lookup_projection(current_path, schema_stack, projection, options) fields.merge!(add_fields(name, relation_projection, options)) end + pipeline.push({ '$addFields' => fields }) unless fields.empty? + pipeline end @@ -36,7 +38,7 @@ def self.add_fields(name, projection, options) projection.filter { |field| field.include?('@@@') } .map { |field_name| "#{name}.#{field_name.tr(":", ".")}" } .each_with_object({}) do |curr, acc| - acc[curr] = "$#{curr.tr("@@@", ".")}" + acc[curr] = "$#{curr.gsub("@@@", ".")}" end end @@ -67,8 +69,10 @@ def self.lookup_relation(current_path, schema_stack, name, projection, options) return [ # Push lookup to pipeline - { '$lookup' => - { 'from' => from, 'localField' => local_field, 'foreignField' => foreign_field, 'as' => as } }, + { + '$lookup' => { 'from' => from, 'localField' => local_field, 'foreignField' => foreign_field, + 'as' => as } + }, { '$unwind' => { 'path' => "$#{as}", 'preserveNullAndEmptyArrays' => true } }, # Recurse to get relations of relations diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/address.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/address.rb index f8976409a..4ce21a83e 100644 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/address.rb +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/address.rb @@ -4,5 +4,6 @@ class Address field :city, type: String field :zip_code, type: String + embeds_one :meta, class_name: 'Meta' embedded_in :user end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/author.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/author.rb index ce60b3be4..53b468fd1 100644 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/author.rb +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/author.rb @@ -5,4 +5,5 @@ class Author field :last_name, type: String belongs_to :post + belongs_to :user end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/co_author.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/co_author.rb new file mode 100644 index 000000000..5f1b11186 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/co_author.rb @@ -0,0 +1,8 @@ +class CoAuthor + include Mongoid::Document + include Mongoid::Timestamps + field :first_name, type: String + field :last_name, type: String + + belongs_to :user +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/meta.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/meta.rb new file mode 100644 index 000000000..d27863e80 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/meta.rb @@ -0,0 +1,6 @@ +class Meta + include Mongoid::Document + field :length, type: String + + embedded_in :address +end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/post.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/post.rb index 5e2ec981b..161c16a91 100644 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/post.rb +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/post.rb @@ -8,4 +8,7 @@ class Post has_many :comments, dependent: :destroy has_one :author has_and_belongs_to_many :tags + belongs_to :user + + embeds_one :co_author end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/user.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/user.rb index 0e2063525..7ed17d574 100644 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/user.rb +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/user.rb @@ -5,4 +5,5 @@ class User field :name, type: String belongs_to :item, polymorphic: true embeds_many :addresses + embeds_one :address end diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator_spec.rb index ca98d5531..da324b275 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/lookup_generator_spec.rb @@ -5,9 +5,9 @@ module Utils module Pipeline include ForestAdminDatasourceToolkit::Components::Query describe LookupGenerator do - let(:stack) { [{ prefix: nil, as_fields: [], as_models: [] }] } - describe 'with the root collection' do + let(:stack) { [{ prefix: nil, as_fields: [], as_models: [] }] } + it 'crashes when non-existent relations are asked for' do projection = Projection.new(['myAuthor:firstname']) expect do @@ -29,28 +29,313 @@ module Pipeline expect(pipeline).to eq([]) end - # TODO: when relations are totally implemented - # it 'should load the post (relation)' do - # projection = Projection.new(['post__many_to_one:title']) - # pipeline = LookupGenerator.lookup(Comment, stack, projection, {}) - # - # expect(pipeline).to eq([ - # { - # '$lookup' => { - # 'from' => 'posts', - # 'localField' => 'post_id', - # 'foreignField' => '_id', - # 'as' => 'post__manyToOne' - # } - # }, - # { - # '$unwind' => { - # 'path' => '$post__manyToOne', - # 'preserveNullAndEmptyArrays' => true - # } - # } - # ]) - # end + it 'loads the post (relation)' do + projection = Projection.new(['post_id__many_to_one:title']) + pipeline = described_class.lookup(Comment, stack, projection, {}) + + expect(pipeline).to eq( + [ + { + '$lookup' => { + 'from' => 'Post', + 'localField' => 'post_id', + 'foreignField' => '_id', + 'as' => 'post_id__many_to_one' + } + }, + { + '$unwind' => { + 'path' => '$post_id__many_to_one', + 'preserveNullAndEmptyArrays' => true + } + } + ] + ) + end + + it 'loads the user (relation) with nested fields' do + projection = Projection.new(['user_id__many_to_one:address@@@city']) + pipeline = described_class.lookup(Post, stack, projection, {}) + + expect(pipeline).to eq( + [ + { + '$lookup' => { + 'as' => 'user_id__many_to_one', + 'foreignField' => '_id', + 'from' => 'User', + 'localField' => 'user_id' + } + }, + { '$unwind' => { 'path' => '$user_id__many_to_one', 'preserveNullAndEmptyArrays' => true } }, + { + '$addFields' => { + 'user_id__many_to_one.address@@@city' => '$user_id__many_to_one.address.city' + } + } + ] + ) + end + + describe 'include' do + it 'returns the nested field if the parent is included' do + projection = Projection.new(['user_id__many_to_one:address@@@city']) + pipeline = described_class.lookup(Post, stack, projection, { include: ['user_id__many_to_one'] }) + + expect(pipeline).to eq( + [ + { + '$lookup' => { + 'as' => 'user_id__many_to_one', + 'foreignField' => '_id', + 'from' => 'User', + 'localField' => 'user_id' + } + }, + { '$unwind' => { 'path' => '$user_id__many_to_one', 'preserveNullAndEmptyArrays' => true } }, + { + '$addFields' => { + 'user_id__many_to_one.address@@@city' => '$user_id__many_to_one.address.city' + } + } + ] + ) + end + + it 'does not add the nested field if the parent is not included' do + projection = Projection.new(['user_id__many_to_one:address@@@city']) + pipeline = described_class.lookup(Post, stack, projection, { include: [] }) + + expect(pipeline).to eq([]) + end + end + + describe 'exclude' do + it 'does not add the nested field if the parent is excluded' do + projection = Projection.new(['user_id__many_to_one:address@@@city']) + pipeline = described_class.lookup(Post, stack, projection, { exclude: ['user_id__many_to_one'] }) + + expect(pipeline).to eq([]) + end + + it 'does not add the nested field if the parent is not included' do + projection = Projection.new(['user_id__many_to_one:address@@@city']) + pipeline = described_class.lookup(Post, stack, projection, { exclude: [] }) + + expect(pipeline).to eq( + [ + { + '$lookup' => { + 'as' => 'user_id__many_to_one', + 'foreignField' => '_id', + 'from' => 'User', + 'localField' => 'user_id' + } + }, + { '$unwind' => { 'path' => '$user_id__many_to_one', 'preserveNullAndEmptyArrays' => true } }, + { + '$addFields' => { + 'user_id__many_to_one.address@@@city' => '$user_id__many_to_one.address.city' + } + } + ] + ) + end + end + + it 'loads the user (relation) with double nested fields' do + projection = Projection.new(['user_id__many_to_one:address@@@meta@@@length']) + pipeline = described_class.lookup(Post, stack, projection, {}) + + expect(pipeline).to eq( + [ + { + '$lookup' => { + 'as' => 'user_id__many_to_one', + 'foreignField' => '_id', + 'from' => 'User', + 'localField' => 'user_id' + } + }, + { '$unwind' => { 'path' => '$user_id__many_to_one', 'preserveNullAndEmptyArrays' => true } }, + { + '$addFields' => { + 'user_id__many_to_one.address@@@meta@@@length' => '$user_id__many_to_one.address.meta.length' + } + } + ] + ) + end + + it 'loads the post user city (double relation with nested field)' do + projection = Projection.new(['post_id__many_to_one:user_id__many_to_one:address@@@city']) + pipeline = described_class.lookup(Author, stack, projection, {}) + + expect(pipeline).to eq( + [ + { + '$lookup' => { + 'as' => 'post_id__many_to_one', + 'foreignField' => '_id', + 'from' => 'Post', + 'localField' => 'post_id' + } + }, + { '$unwind' => { 'path' => '$post_id__many_to_one', 'preserveNullAndEmptyArrays' => true } }, + { + '$lookup' => { + 'as' => 'post_id__many_to_one.user_id__many_to_one', + 'foreignField' => '_id', + 'from' => 'User', + 'localField' => 'post_id__many_to_one.user_id' + } + }, + { '$unwind' => { 'path' => '$post_id__many_to_one.user_id__many_to_one', 'preserveNullAndEmptyArrays' => true } }, + { + '$addFields' => { + 'user_id__many_to_one.address@@@city' => '$user_id__many_to_one.address.city' + } + }, + { + '$addFields' => { + 'post_id__many_to_one.user_id__many_to_one.address@@@city' => '$post_id__many_to_one.user_id__many_to_one.address.city' + } + } + ] + ) + end + + it 'loads the post user address meta length (double relation with double nested field)' do + projection = Projection.new(['post_id__many_to_one:user_id__many_to_one:address@@@meta@@@length']) + pipeline = described_class.lookup(Author, stack, projection, {}) + + expect(pipeline).to eq( + [ + { + '$lookup' => { + 'as' => 'post_id__many_to_one', + 'foreignField' => '_id', + 'from' => 'Post', + 'localField' => 'post_id' + } + }, + { '$unwind' => { 'path' => '$post_id__many_to_one', 'preserveNullAndEmptyArrays' => true } }, + { + '$lookup' => { + 'as' => 'post_id__many_to_one.user_id__many_to_one', + 'foreignField' => '_id', + 'from' => 'User', + 'localField' => 'post_id__many_to_one.user_id' + } + }, + { '$unwind' => { 'path' => '$post_id__many_to_one.user_id__many_to_one', 'preserveNullAndEmptyArrays' => true } }, + { + '$addFields' => { + 'user_id__many_to_one.address@@@meta@@@length' => '$user_id__many_to_one.address.meta.length' + } + }, + { + '$addFields' => { + 'post_id__many_to_one.user_id__many_to_one.address@@@meta@@@length' => '$post_id__many_to_one.user_id__many_to_one.address.meta.length' + } + } + ] + ) + end + + it 'loads the post co_author user (relation within fake relation)' do + projection = Projection.new(['co_author:user_id__many_to_one:name']) + pipeline = described_class.lookup(Post, stack, projection, {}) + + expect(pipeline).to eq( + [ + { + '$lookup' => { + 'as' => 'co_author.user_id__many_to_one', + 'foreignField' => '_id', + 'from' => 'User', + 'localField' => 'co_author.user_id' + } + }, + { '$unwind' => { 'path' => '$co_author.user_id__many_to_one', 'preserveNullAndEmptyArrays' => true } } + ] + ) + end + + it 'loads the comment post user (nested relation)' do + projection = Projection.new(['post_id__many_to_one:user_id__many_to_one:name']) + pipeline = described_class.lookup(Comment, stack, projection, {}) + + expect(pipeline).to eq( + [ + { + '$lookup' => { + 'as' => 'post_id__many_to_one', + 'foreignField' => '_id', + 'from' => 'Post', + 'localField' => 'post_id' + } + }, + { '$unwind' => { 'path' => '$post_id__many_to_one', 'preserveNullAndEmptyArrays' => true } }, + { + '$lookup' => { + 'as' => 'post_id__many_to_one.user_id__many_to_one', + 'foreignField' => '_id', + 'from' => 'User', + 'localField' => 'post_id__many_to_one.user_id' + } + }, + { '$unwind' => { 'path' => '$post_id__many_to_one.user_id__many_to_one', 'preserveNullAndEmptyArrays' => true } } + ] + ) + end + end + + describe 'with a reparented collection' do + let(:stack) do + [ + { prefix: nil, as_fields: [], as_models: ['co_author'] }, + { prefix: 'co_author', as_fields: [], as_models: [] } + ] + end + + it 'loads the post co_author user (relation)' do + projection = Projection.new(['user_id__many_to_one:name']) + pipeline = described_class.lookup(Post, stack, projection, {}) + + expect(pipeline).to eq( + [ + { + '$lookup' => { + 'as' => 'user_id__many_to_one', + 'foreignField' => '_id', + 'from' => 'User', + 'localField' => 'user_id' + } + }, + { '$unwind' => { 'path' => '$user_id__many_to_one', 'preserveNullAndEmptyArrays' => true } } + ] + ) + end + + it 'loads the user (relation within fake relation)' do + projection = Projection.new(['parent:user_id__many_to_one:name']) + pipeline = described_class.lookup(Post, stack, projection, {}) + + expect(pipeline).to eq( + [ + { + '$lookup' => { + 'as' => 'parent.user_id__many_to_one', + 'foreignField' => '_id', + 'from' => 'User', + 'localField' => 'parent.user_id' + } + }, + { '$unwind' => { 'path' => '$parent.user_id__many_to_one', 'preserveNullAndEmptyArrays' => true } } + ] + ) + end end end end From 5c3ff5e99d53516ef275f3e8a5b0686219a048d8 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 27 Feb 2025 16:06:10 +0100 Subject: [PATCH 091/104] chore: add test on FieldGenerator --- .../utils/schema/field_flattener_spec.rb | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/schema/field_flattener_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/schema/field_flattener_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/schema/field_flattener_spec.rb new file mode 100644 index 000000000..97166ef4d --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/schema/field_flattener_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + module Utils + module Schema + describe FieldsGenerator do + let(:stack) { [{ prefix: nil, as_fields: [], as_models: [] }] } + + describe 'Construction' do + it 'does not modify the schema' do + fields = described_class.build_fields_schema(Post, stack) + expect(fields.keys).to eq(%w[_id created_at updated_at title body rating tag_ids]) + end + + it 'skips flattened models' do + stack = [{ prefix: nil, as_fields: ['section.body'], as_models: ['section'] }] + fields = described_class.build_fields_schema(Label, stack) + + expect(fields.keys).to eq(['_id', 'name', 'section@@@body']) + end + + it 'flattens all nested fields when no level is provided' do + stack = [{ prefix: nil, as_fields: ['section.content', 'section.body'], as_models: [] }] + fields = described_class.build_fields_schema(Label, stack) + + expect(fields.keys).to eq(['_id', 'name', 'section@@@content', 'section@@@body']) + end + + it 'flattens only requested fields' do + stack = [{ prefix: nil, as_fields: ['label.name'], as_models: [] }] + fields = described_class.build_fields_schema(Band, stack) + + expect(fields.keys).to eq(['_id', 'created_at', 'updated_at', 'label', 'label@@@name']) + end + + it 'onlies flatten selected fields when level = 1' do + stack = [{ prefix: nil, as_fields: ['label.name', 'label.section'], as_models: [] }] + fields = described_class.build_fields_schema(Band, stack) + + expect(fields.keys).to eq(['_id', 'created_at', 'updated_at', 'label@@@name', 'label@@@section']) + end + + it 'returns a "String" as type when "_id" is generated' do + fields = described_class.build_fields_schema(Post, stack) + + expect(fields['_id'].column_type).to eq('String') + end + end + end + end + end +end From 41ef49cf27d893f70e34d601ffc3ca3efbc6c9be Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Thu, 27 Feb 2025 16:38:30 +0100 Subject: [PATCH 092/104] fix: some tests --- .../rename_field/rename_field_collection_decorator_spec.rb | 2 +- .../create_relations/collection_single_many_to_one_spec.rb | 6 +++--- .../create_relations/collection_single_one_to_one_spec.rb | 2 +- .../write/update_relations/collection_many_to_one_spec.rb | 4 ++-- .../write/update_relations/collection_one_to_one_spec.rb | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/rename_field/rename_field_collection_decorator_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/rename_field/rename_field_collection_decorator_spec.rb index 82052cc0b..c89b61bd1 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/rename_field/rename_field_collection_decorator_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/rename_field/rename_field_collection_decorator_spec.rb @@ -274,7 +274,7 @@ module RenameField result = @new_person.list(caller, new_person_filter, projection) expect(@collection_person).to have_received(:list) do |_caller, _filter, base_projection| - expect(base_projection.to_a).to eq(%w[my_book_person:date id]) + expect(base_projection.to_a).to eq(%w[id my_book_person:date]) end expect(result).to eq([{ 'primary_key' => '1', 'my_novel_author' => { 'created_at' => 'something' } }]) end diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_single_many_to_one_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_single_many_to_one_spec.rb index b93cd326e..b0341f8cd 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_single_many_to_one_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_single_many_to_one_spec.rb @@ -79,7 +79,7 @@ module WriteReplace expect(@collection_book).to have_received(:list) do |context_caller, _filter, projection| expect(context_caller).to eq caller - expect(projection).to eq Projection.new(['my_author:id', 'id']) + expect(projection).to eq Projection.new(%w[id my_author:id]) end expect(@collection_person).to have_received(:update) do |context_caller, filter, data| @@ -114,7 +114,7 @@ module WriteReplace @decorated_book.update(caller, Filter.new, { 'title' => 'a title' }) expect(@collection_book).to have_received(:list) do |_caller, _filter, projection| - expect(projection).to eq Projection.new(['my_author:id', 'id']) + expect(projection).to eq Projection.new(%w[id my_author:id]) end expect(@collection_person).to have_received(:list) do |_caller, filter, projection| @@ -126,7 +126,7 @@ module WriteReplace segment: nil, sort: nil ) - expect(projection).to eq Projection.new(['my_price:id', 'id']) + expect(projection).to eq Projection.new(%w[id my_price:id]) end expect(@collection_price).to have_received(:update) do |_caller, filter, data| diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_single_one_to_one_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_single_one_to_one_spec.rb index fe2b6f41c..41ce6911b 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_single_one_to_one_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/create_relations/collection_single_one_to_one_spec.rb @@ -78,7 +78,7 @@ module WriteReplace segment: nil, sort: nil ) - expect(projection).to eq Projection.new(['my_owner:id', 'id']) + expect(projection).to eq Projection.new(%w[id my_owner:id]) end expect(@collection_owner).to have_received(:update) do |_caller, filter_, data| expect(filter_).to have_attributes( diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/update_relations/collection_many_to_one_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/update_relations/collection_many_to_one_spec.rb index faf60e506..44ddf6aa6 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/update_relations/collection_many_to_one_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/update_relations/collection_many_to_one_spec.rb @@ -75,7 +75,7 @@ module UpdateRelations ) # Check that the decorator listed the authors to update - expect(@collection_book).to have_received(:list).with(caller, filter, Projection.new(['author:id', 'id'])) + expect(@collection_book).to have_received(:list).with(caller, filter, Projection.new(%w[id author:id])) # Check that the normal update was made (first call on update) # Check that the author was created and the book updated with the new author id expect(@collection_author).to have_received(:create).with(caller, { 'first_name' => 'John' }) @@ -130,7 +130,7 @@ module UpdateRelations ) # Check that the decorator listed the authors to update - expect(@collection_book).to have_received(:list).with(caller, filter, Projection.new(['author:id', 'id'])) + expect(@collection_book).to have_received(:list).with(caller, filter, Projection.new(%w[id author:id])) # Check that the update was made on both collections expect(@collection_book).to have_received(:update).with(caller, filter, { 'title' => 'New title' }) expect(@collection_author).to have_received(:update) do |caller_, filter_, data| diff --git a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/update_relations/collection_one_to_one_spec.rb b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/update_relations/collection_one_to_one_spec.rb index 7b427ad2e..46a50587f 100644 --- a/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/update_relations/collection_one_to_one_spec.rb +++ b/packages/forest_admin_datasource_customizer/spec/lib/forest_admin_datasource_customizer/decorators/write/update_relations/collection_one_to_one_spec.rb @@ -75,7 +75,7 @@ module UpdateRelations ) # Check that the decorator listed the authors to update - expect(@collection_book).to have_received(:list).with(caller, filter, Projection.new(['author:id', 'id'])) + expect(@collection_book).to have_received(:list).with(caller, filter, Projection.new(%w[id author:id])) # Check that the normal update was made expect(@collection_book).to have_received(:update).with(caller, filter, { 'title' => 'New title' }) @@ -100,7 +100,7 @@ module UpdateRelations ) # Check that the decorator listed the authors to update - expect(@collection_book).to have_received(:list).with(caller, filter, Projection.new(['author:id', 'id'])) + expect(@collection_book).to have_received(:list).with(caller, filter, Projection.new(%w[id author:id])) # Check that the update was made on both collections expect(@collection_book).to have_received(:update).with(caller, filter, { 'title' => 'New title' }) From c8cec7df832982199201452fac7339d06efb4ae2 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Thu, 27 Feb 2025 17:21:26 +0100 Subject: [PATCH 093/104] refactor: remove unused code --- .../collection.rb | 5 - .../utils/query.rb | 165 ------------- .../utils/query_aggregate.rb | 60 ----- .../utils/query_aggregate_spec.rb | 90 ------- .../utils/query_spec.rb | 219 ------------------ 5 files changed, 539 deletions(-) delete mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb delete mode 100644 packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query_aggregate.rb delete mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/query_aggregate_spec.rb delete mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/query_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index 0a39bcf6f..832ba9b1f 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -49,7 +49,6 @@ def aggregate(_caller, filter, aggregation, limit = nil) def create(caller, data) handle_validation_error { _create(caller, data) } - # Utils::MongoidSerializer.new(model.create(data)).to_hash(ProjectionFactory.all(self)) end def _create(_caller, flat_data) @@ -74,8 +73,6 @@ def _create(_caller, flat_data) def update(caller, filter, data) handle_validation_error { _update(caller, filter, data) } - # entity = Utils::Query.new(self, nil, filter).build.first - # entity&.update(data) end def _update(_caller, filter, flat_patch) @@ -100,8 +97,6 @@ def _update(_caller, filter, flat_patch) def delete(caller, filter) handle_validation_error { _delete(caller, filter) } - # entities = Utils::Query.new(self, nil, filter).build - # entities&.each(&:destroy) end def _delete(_caller, filter) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb deleted file mode 100644 index 5b65dd270..000000000 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query.rb +++ /dev/null @@ -1,165 +0,0 @@ -module ForestAdminDatasourceMongoid - module Utils - class Query - include ForestAdminDatasourceToolkit::Components::Query::ConditionTree - - attr_reader :query - - def initialize(collection, projection, filter) - @collection = collection - @query = @collection.model.unscoped - @projection = projection - @filter = filter - @select = [] - @virtual_joins = {} - end - - def build - build_select - apply_filter - apply_select - end - - def get - build - return @query.all if @filter.page.nil? - - @query.skip(@filter.page.offset).limit(@filter.page.limit) - end - - def apply_filter - @query = apply_condition_tree(@filter.condition_tree) unless @filter.condition_tree.nil? - @query = apply_referenced_relations - @query = apply_sort(@filter.sort) unless @filter.sort.nil? - - @query - end - - def apply_referenced_relations - @virtual_joins.each do |relation_name, match| - relation = @collection.schema[:fields][relation_name] - if relation.type == 'OneToOne' - @query = @query.in(relation.origin_key_target => match.distinct(relation.origin_key)) - elsif relation.type == 'ManyToOne' - @query = @query.in(relation.foreign_key => match.distinct(relation.foreign_key_target)) - end - end - @query - end - - def apply_sort(sort) - sort.each do |sort_clause| - field = format_field(sort_clause[:field]) - @query = @query.order(field => sort_clause[:ascending] ? :asc : :desc) - end - - @query - end - - def apply_pagination(page) - @query.offset(page.offset).limit(page.limit) - - @query - end - - def apply_condition_tree(condition_tree, aggregator = nil) - if condition_tree.is_a? Nodes::ConditionTreeBranch - aggregator = condition_tree.aggregator.downcase.to_sym - condition_tree.conditions.each do |condition| - @query = apply_condition_tree(condition, aggregator) - end - elsif referenced_relation?(condition_tree.field) - relation_name, field_name = condition_tree.field.split(':') - relation = @collection.model.relations[relation_name] - @virtual_joins[relation_name] = relation.klass.unscoped unless @virtual_joins[relation_name] - @virtual_joins[relation_name] = compute_main_operator( - @virtual_joins[relation_name], - condition_tree.override(field: field_name), - aggregator || :and - ) - else - @query = compute_main_operator(@query, condition_tree, aggregator || :and) - end - - @query - end - - def compute_main_operator(query, condition_tree, aggregator) - field = format_field(condition_tree.field) - value = condition_tree.value - aggregator = aggregator.to_sym - - case condition_tree.operator - when Operators::PRESENT - query = query.send(aggregator, @collection.model.not.where(field => nil)) - when Operators::EQUAL - query = query.send(aggregator, @collection.model.where(field => value)) - when Operators::IN - query = query.send(aggregator, @collection.model.in(field => value)) - when Operators::NOT_EQUAL - query = query.send(aggregator, @collection.model.not.where(field => value)) - when Operators::NOT_IN - query = query.send(aggregator, @collection.model.not.in(field => value)) - when Operators::GREATER_THAN - query = query.send(aggregator, @collection.model.where(field => { '$gt' => value })) - when Operators::LESS_THAN - query = query.send(aggregator, @collection.model.where(field => { '$lt' => value })) - when Operators::NOT_CONTAINS - query = query.send(aggregator, @collection.model.not.where(field => Regexp.new("^.*#{value}.*$"))) - when Operators::NOT_I_CONTAINS - query = query.send(aggregator, @collection.model.not.where(field => Regexp.new("^.*#{value}.*$", 'i'))) - when Operators::MATCH - query = query.send(aggregator, @collection.model.where(field => { '$regex' => value })) - when Operators::INCLUDES_ALL - query = query.send(aggregator, @collection.model.where(field => { '$all' => value })) - end - - query - end - - def referenced_relation?(path) - return false unless path.include?(':') - - relation_name, = path.split(':') - - @collection.model.relations.key?(relation_name) && - !@collection.model.relations[relation_name].embedded? - end - - def build_select - return if @projection.nil? - - @select = @projection.columns - @projection.relations.each_key do |relation| - relation_schema = @collection.schema[:fields][relation] - @select << if %w[OneToOne PolymorphicOneToOne].include?(relation_schema.type) - "#{@collection.model.collection_name}.#{relation_schema.origin_key_target}" - else - "#{@collection.model.collection_name}.#{relation_schema.foreign_key}" - end - end - end - - def apply_select - @query = @query.only(@select) if @select - @query = @query.includes(format_relation_projection(@projection)) unless @projection.nil? - - @query - end - - def format_field(field) - field.tr(':', '.') - end - - def format_relation_projection(projection) - result = [] - projection&.relations&.each_value do |projection_relation| - formatted_relations = format_relation_projection(projection_relation) - result += formatted_relations - end - - result - end - end - end -end diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query_aggregate.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query_aggregate.rb deleted file mode 100644 index 5edd11861..000000000 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/query_aggregate.rb +++ /dev/null @@ -1,60 +0,0 @@ -module ForestAdminDatasourceMongoid - module Utils - class QueryAggregate < Query - include ForestAdminDatasourceToolkit::Components::Query::ConditionTree - include ForestAdminDatasourceToolkit::Components::Query - - GROUP_OPERATION = { - 'Year' => '%Y-01-01', - 'Month' => '%Y-%m-01', - 'Day' => '%Y-%m-%d', - 'Week' => '%Y-%m-%d' - }.freeze - - def initialize(collection, aggregation, filter, limit = nil) - super(collection, ForestAdminDatasourceToolkit::Components::Query::Projection.new, filter) - @aggregation = aggregation - @limit = limit - @operation = aggregation.operation.downcase - @field = aggregation.field.nil? ? {} : format_field(aggregation.field) - end - - def get - build - @query = @query.limit(@limit) if @limit - @query = @query.group(_id: compute_groups, value: { "$#{@operation}" => @field }) - .project(_id: 0, value: '$value', group: compute_groups_projection) - - @query.collection.aggregate(@query.pipeline).to_a - end - - private - - def compute_groups - @aggregation.groups.reduce({}) do |memo, group| - field = "$#{format_field(group[:field])}" - - if group.key?(:operation) - if group[:operation] == 'Week' - date = { '$dateTrunc' => { 'date' => field, 'startOfWeek' => 'Monday', 'unit' => 'week' } } - field = { '$dateToString' => { 'format' => GROUP_OPERATION[group[:operation]], 'date' => date } } - else - field = { '$dateToString' => { 'format' => GROUP_OPERATION[group[:operation]], 'date' => field } } - end - end - - memo.merge({ group[:field] => field }) - end - end - - def compute_groups_projection - if @aggregation.groups.length.positive? - return @aggregation.groups - .reduce({}) { |memo, group| memo.merge({ group[:field] => "$_id.#{group[:field]}" }) } - end - - { '$literal' => {} } - end - end - end -end diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/query_aggregate_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/query_aggregate_spec.rb deleted file mode 100644 index 3ee534bdf..000000000 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/query_aggregate_spec.rb +++ /dev/null @@ -1,90 +0,0 @@ -require 'spec_helper' - -module ForestAdminDatasourceMongoid - module Utils - include ForestAdminDatasourceToolkit::Components::Query - include ForestAdminDatasourceToolkit::Components::Query::ConditionTree - include ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes - describe QueryAggregate do - let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new } - let(:collection) { datasource.get_collection('Post') } - - describe 'apply limit' do - it 'add limit clauses to the query' do - aggregation = Aggregation.new(operation: 'Count') - query_aggregate = described_class.new(collection, aggregation, Filter.new, 10) - query_aggregate.get - - expect(query_aggregate.query.options).to eq({ limit: 10 }) - end - end - - describe 'apply aggregate operation' do - it 'add count operation to the query' do - aggregation = Aggregation.new(operation: 'Count') - query_aggregate = described_class.new(collection, aggregation, Filter.new) - query_aggregate.get - - expect(query_aggregate.query.pipeline).to eq( - [ - { '$group' => { '_id' => {}, 'value' => { '$count' => {} } } }, - { '$project' => { '_id' => 0, 'group' => { '$literal' => {} }, 'value' => '$value' } } - ] - ) - end - - it 'add sum operation to the query' do - aggregation = Aggregation.new(operation: 'Sum', field: 'rating') - query_aggregate = described_class.new(collection, aggregation, Filter.new) - query_aggregate.get - - expect(query_aggregate.query.pipeline).to eq( - [ - { '$group' => { '_id' => {}, 'value' => { '$sum' => 'rating' } } }, - { '$project' => { '_id' => 0, 'group' => { '$literal' => {} }, 'value' => '$value' } } - ] - ) - end - - it 'add avg operation to the query' do - aggregation = Aggregation.new(operation: 'Avg', field: 'rating') - query_aggregate = described_class.new(collection, aggregation, Filter.new) - query_aggregate.get - - expect(query_aggregate.query.pipeline).to eq( - [ - { '$group' => { '_id' => {}, 'value' => { '$avg' => 'rating' } } }, - { '$project' => { '_id' => 0, 'group' => { '$literal' => {} }, 'value' => '$value' } } - ] - ) - end - - it 'add min operation to the query' do - aggregation = Aggregation.new(operation: 'Min', field: 'rating') - query_aggregate = described_class.new(collection, aggregation, Filter.new) - query_aggregate.get - - expect(query_aggregate.query.pipeline).to eq( - [ - { '$group' => { '_id' => {}, 'value' => { '$min' => 'rating' } } }, - { '$project' => { '_id' => 0, 'group' => { '$literal' => {} }, 'value' => '$value' } } - ] - ) - end - - it 'add max operation to the query' do - aggregation = Aggregation.new(operation: 'Max', field: 'rating') - query_aggregate = described_class.new(collection, aggregation, Filter.new) - query_aggregate.get - - expect(query_aggregate.query.pipeline).to eq( - [ - { '$group' => { '_id' => {}, 'value' => { '$max' => 'rating' } } }, - { '$project' => { '_id' => 0, 'group' => { '$literal' => {} }, 'value' => '$value' } } - ] - ) - end - end - end - end -end diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/query_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/query_spec.rb deleted file mode 100644 index 1af1c0163..000000000 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/query_spec.rb +++ /dev/null @@ -1,219 +0,0 @@ -require 'spec_helper' - -module ForestAdminDatasourceMongoid - module Utils - include ForestAdminDatasourceToolkit::Components::Query - include ForestAdminDatasourceToolkit::Components::Query::ConditionTree - include ForestAdminDatasourceToolkit::Components::Query::ConditionTree::Nodes - describe Query do - let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new } - let(:collection) { datasource.get_collection('Post') } - - describe 'apply sort' do - it 'add sort clauses to the query' do - sort = Sort.new([{ field: 'title', ascending: true }, { field: 'body', ascending: false }]) - filter = Filter.new(sort: sort) - query_builder = described_class.new(collection, Projection.new, filter) - query_builder.build - - expect(query_builder.query.options).to eq({ sort: { 'body' => -1, 'title' => 1 } }) - end - end - - describe 'apply pagination' do - it 'add pagination clause to the query' do - page = Page.new(offset: 10, limit: 20) - filter = Filter.new(page: page) - query_builder = described_class.new(collection, Projection.new, filter) - query = query_builder.get - - expect(query.options).to eq({ skip: 10, limit: 20 }) - end - - it 'do not add limitation clause to the query if pagination is missing' do - query_builder = described_class.new(collection, Projection.new, Filter.new) - query = query_builder.get - - expect(query.options).to eq({}) - end - end - - describe 'apply projection' do - it 'add projection to the query' do - projection = Projection.new(%w[_id title body]) - query_builder = described_class.new(collection, projection, Filter.new) - query_builder.build - - expect(query_builder.query.options).to eq({ fields: { '_id' => 1, 'title' => 1, 'body' => 1 } }) - end - end - - describe 'apply condition tree' do - context 'with simple condition tree' do - it 'add present condition' do - filter = Filter.new( - condition_tree: ConditionTreeLeaf.new('title', Operators::PRESENT) - ) - query_builder = described_class.new(collection, Projection.new, filter) - query_builder.build - - expect(query_builder.query.selector).to eq({ 'title' => { '$ne' => nil } }) - end - - it 'add equal condition' do - filter = Filter.new( - condition_tree: ConditionTreeLeaf.new('title', Operators::EQUAL, 'foo') - ) - query_builder = described_class.new(collection, Projection.new, filter) - query_builder.build - - expect(query_builder.query.selector).to eq({ 'title' => 'foo' }) - end - - it 'add in condition' do - filter = Filter.new( - condition_tree: ConditionTreeLeaf.new('title', Operators::IN, 'foo') - ) - query_builder = described_class.new(collection, Projection.new, filter) - query_builder.build - - expect(query_builder.query.selector).to eq({ 'title' => { '$in' => ['foo'] } }) - end - - it 'add not_equal condition' do - filter = Filter.new( - condition_tree: ConditionTreeLeaf.new('title', Operators::NOT_EQUAL, 'foo') - ) - query_builder = described_class.new(collection, Projection.new, filter) - query_builder.build - - expect(query_builder.query.selector).to eq({ 'title' => { '$ne' => 'foo' } }) - end - - it 'add not_in condition' do - filter = Filter.new( - condition_tree: ConditionTreeLeaf.new('title', Operators::NOT_IN, 'foo') - ) - query_builder = described_class.new(collection, Projection.new, filter) - query_builder.build - - expect(query_builder.query.selector).to eq({ 'title' => { '$not' => { '$in' => ['foo'] } } }) - end - - it 'add greater_than condition' do - filter = Filter.new( - condition_tree: ConditionTreeLeaf.new('title', Operators::GREATER_THAN, 1) - ) - query_builder = described_class.new(collection, Projection.new, filter) - query_builder.build - - expect(query_builder.query.selector).to eq({ 'title' => { '$gt' => '1' } }) - end - - it 'add less_than condition' do - filter = Filter.new( - condition_tree: ConditionTreeLeaf.new('title', Operators::LESS_THAN, 1) - ) - query_builder = described_class.new(collection, Projection.new, filter) - query_builder.build - - expect(query_builder.query.selector).to eq({ 'title' => { '$lt' => '1' } }) - end - - it 'add not_contains condition' do - filter = Filter.new( - condition_tree: ConditionTreeLeaf.new('title', Operators::NOT_CONTAINS, 'foo') - ) - query_builder = described_class.new(collection, Projection.new, filter) - query_builder.build - - expect(query_builder.query.selector).to eq({ 'title' => { '$not' => /^.*foo.*$/ } }) - end - - it 'add not_i_contains condition' do - filter = Filter.new( - condition_tree: ConditionTreeLeaf.new('title', Operators::NOT_I_CONTAINS, 'foo') - ) - query_builder = described_class.new(collection, Projection.new, filter) - query_builder.build - - expect(query_builder.query.selector).to eq({ 'title' => { '$not' => /^.*foo.*$/i } }) - end - - it 'add match condition' do - filter = Filter.new( - condition_tree: ConditionTreeLeaf.new('title', Operators::MATCH, 'foo') - ) - query_builder = described_class.new(collection, Projection.new, filter) - query_builder.build - - expect(query_builder.query.selector).to eq({ 'title' => { '$regex' => 'foo' } }) - end - end - - context 'with condition tree branch' do - it 'add condition with "and" aggregator' do - filter = Filter.new( - condition_tree: ConditionTreeBranch.new( - 'and', - [ - ConditionTreeLeaf.new('title', Operators::PRESENT), - ConditionTreeLeaf.new('body', Operators::PRESENT) - ] - ) - ) - query_builder = described_class.new(collection, Projection.new, filter) - query_builder.build - - expect(query_builder.query.selector).to eq( - { 'body' => { '$ne' => nil }, 'title' => { '$ne' => nil } } - ) - end - - it 'add condition with "or" aggregator' do - filter = Filter.new( - condition_tree: ConditionTreeBranch.new( - 'or', - [ - ConditionTreeLeaf.new('title', Operators::PRESENT), - ConditionTreeLeaf.new('body', Operators::PRESENT) - ] - ) - ) - query_builder = described_class.new(collection, Projection.new, filter) - query_builder.build - - expect(query_builder.query.selector).to eq( - { '$or' => [{ 'title' => { '$ne' => nil } }, { 'body' => { '$ne' => nil } }] } - ) - end - - it 'add condition with nested condition tree branch' do - filter = Filter.new( - condition_tree: ConditionTreeBranch.new( - 'and', - [ - ConditionTreeBranch.new( - 'or', - [ - ConditionTreeLeaf.new('title', Operators::PRESENT), - ConditionTreeLeaf.new('body', Operators::PRESENT) - ] - ), - ConditionTreeLeaf.new('title', Operators::PRESENT), - ConditionTreeLeaf.new('body', Operators::PRESENT) - ] - ) - ) - query_builder = described_class.new(collection, Projection.new, filter) - query_builder.build - - expect(query_builder.query.selector).to eq( - { '$or' => [{ 'title' => { '$ne' => nil } }, { 'body' => { '$ne' => nil } }], 'body' => { '$ne' => nil }, 'title' => { '$ne' => nil } } - ) - end - end - end - end - end -end From 6d82eb702cdd24004ce9cd49c264ad637abced0f Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Thu, 27 Feb 2025 17:42:16 +0100 Subject: [PATCH 094/104] fix: some tests --- .../routes/resources/related/list_related_spec.rb | 4 ++-- .../spec/dummy/app/models/user.rb | 2 +- .../collection_spec.rb | 15 +++++++++++++-- .../datasource_spec.rb | 2 +- .../helpers_spec.rb | 12 ++++++------ .../parser/relation_spec.rb | 2 +- .../parser/validation_spec.rb | 2 +- .../utils/mongoid_serializer_spec.rb | 4 +++- .../utils/pipeline/filter_generator_spec.rb | 2 +- .../utils/schema/field_flattener_spec.rb | 14 +++++++------- 10 files changed, 36 insertions(+), 23 deletions(-) diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/related/list_related_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/related/list_related_spec.rb index 12c7a8b77..ebdbb7775 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/related/list_related_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/related/list_related_spec.rb @@ -97,7 +97,7 @@ module Related search: nil, search_extended: nil, segment: nil, - sort: nil + sort: [{ ascending: true, field: 'id' }] ) expect(projection).to eq(%w[id label]) end @@ -130,7 +130,7 @@ module Related search: nil, search_extended: nil, segment: nil, - sort: nil + sort: [{ ascending: true, field: 'id' }] ) expect(projection).to eq(%w[id label]) end diff --git a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/user.rb b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/user.rb index 7ed17d574..365334c81 100644 --- a/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/user.rb +++ b/packages/forest_admin_datasource_mongoid/spec/dummy/app/models/user.rb @@ -3,7 +3,7 @@ class User include Mongoid::Timestamps field :name, type: String - belongs_to :item, polymorphic: true + belongs_to :item, polymorphic: true, optional: true embeds_many :addresses embeds_one :address end diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb index 800f3529a..cdd4c6d3d 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb @@ -8,7 +8,7 @@ module ForestAdminDatasourceMongoid allow(ForestAdminAgent::Facades::Container).to receive(:logger).and_return(logger) end - let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new } + let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new(options: { flatten_mode: 'auto' }) } let(:collection_post) { described_class.new(datasource, Post, [{ prefix: nil, as_fields: [], as_models: [] }]) } let(:collection_user) { described_class.new(datasource, User, [{ prefix: nil, as_fields: [], as_models: [] }]) } let(:collection_departure) { described_class.new(datasource, Departure, [{ prefix: nil, as_fields: [], as_models: [] }]) } @@ -28,7 +28,18 @@ module ForestAdminDatasourceMongoid expect(schema[:countable]).to be(true) expect(schema[:searchable]).to be(false) expect(schema[:segments]).to eq([]) - expect(schema[:fields].keys).to include('_id', 'created_at', 'updated_at', 'title', 'body', 'rating', 'tag_ids', 'tag_ids__many_to_one') + expect(schema[:fields].keys).to include( + '_id', + 'created_at', + 'updated_at', + 'title', + 'body', + 'rating', + 'tag_ids', + 'user_id', + 'user_id__many_to_one', + 'co_author' + ) end it 'escapes collection names' do diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb index 90207c66c..ebcb0cfb3 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb @@ -11,7 +11,7 @@ module ForestAdminDatasourceMongoid end it 'adds collections to the datasource' do - expected = %w[Band Author Tag Comment Departure Post User Team addresses_User] + expected = %w[Band Author Tag Comment Departure Post User Team addresses_User CoAuthor Dummy__MyModel] expect(datasource.collections.keys).to match_array(expected) end diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/helpers_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/helpers_spec.rb index f21302985..572883d76 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/helpers_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/helpers_spec.rb @@ -19,14 +19,14 @@ module Utils it 'compares using both ordinal and lexicographical order' do expect(helpers_module.compare_ids('a', 'a')).to eq(0) - expect(helpers_module.compare_ids('a', 'b')).to be.negative? - expect(helpers_module.compare_ids('b', 'a')).to be.positive? + expect(helpers_module.compare_ids('a', 'b')).to be_negative + expect(helpers_module.compare_ids('b', 'a')).to be_positive - expect(helpers_module.compare_ids('a', 'a.b')).to be.negative? - expect(helpers_module.compare_ids('a.b', 'a')).to be.positive? + expect(helpers_module.compare_ids('a', 'a.b')).to be_negative + expect(helpers_module.compare_ids('a.b', 'a')).to be_positive - expect(helpers_module.compare_ids('a.2.b', 'a.10.a')).to be.negative? - expect(helpers_module.compare_ids('a.10.a', 'a.2.b')).to be.positive? + expect(helpers_module.compare_ids('a.2.b', 'a.10.a')).to be_negative + expect(helpers_module.compare_ids('a.10.a', 'a.2.b')).to be_positive end it 'splitId should separate rootId and path' do diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/relation_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/relation_spec.rb index 68d2d15c4..cad5705b3 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/relation_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/relation_spec.rb @@ -9,7 +9,7 @@ module Parser allow(ForestAdminAgent::Facades::Container).to receive(:logger).and_return(logger) end - let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new } + let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new(options: { flatten_mode: 'auto' }) } let(:collection) { ForestAdminDatasourceMongoid::Collection.new(datasource, model_class, [{ prefix: nil, as_fields: [], as_models: [] }]) } context 'when models with polymorphic relations exist' do diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/validation_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/validation_spec.rb index f9012148d..8d29d7eb0 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/validation_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/validation_spec.rb @@ -9,7 +9,7 @@ module Parser allow(ForestAdminAgent::Facades::Container).to receive(:logger).and_return(logger) end - let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new } + let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new(options: { flatten_mode: 'auto' }) } let(:collection) { ForestAdminDatasourceMongoid::Collection.new(datasource, model, [{ prefix: nil, as_fields: [], as_models: [] }]) } context 'when the model has a before_validation callback' do diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer_spec.rb index 9cc2d642c..9b9d57d08 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/mongoid_serializer_spec.rb @@ -26,10 +26,12 @@ module Utils let(:author) { Author.first } before do + user = User.create!(name: 'Sarah Connor') Author.create!( first_name: 'john', last_name: 'doe', - post: Post.create!(title: 'foo', body: 'fake content') + post: Post.create!(title: 'foo', body: 'fake content', user: user), + user: user ) end diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator_spec.rb index d56d407ed..7dff22682 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/filter_generator_spec.rb @@ -10,7 +10,7 @@ module Pipeline allow(ForestAdminAgent::Facades::Container).to receive(:logger).and_return(logger) end - let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new } + let(:datasource) { ForestAdminDatasourceMongoid::Datasource.new(options: { flatten_mode: 'auto' }) } let(:model) { datasource.get_collection('Post').model } let(:stack) { [{ prefix: nil, as_fields: [], as_models: [] }] } diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/schema/field_flattener_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/schema/field_flattener_spec.rb index 97166ef4d..a28999c89 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/schema/field_flattener_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/schema/field_flattener_spec.rb @@ -9,35 +9,35 @@ module Schema describe 'Construction' do it 'does not modify the schema' do fields = described_class.build_fields_schema(Post, stack) - expect(fields.keys).to eq(%w[_id created_at updated_at title body rating tag_ids]) + expect(fields.keys).to eq(%w[_id created_at updated_at title body rating tag_ids user_id user_id__many_to_one co_author]) end it 'skips flattened models' do stack = [{ prefix: nil, as_fields: ['section.body'], as_models: ['section'] }] fields = described_class.build_fields_schema(Label, stack) - expect(fields.keys).to eq(['_id', 'name', 'section@@@body']) + expect(fields.keys).to eq(%w[_id name section@@@body]) end it 'flattens all nested fields when no level is provided' do - stack = [{ prefix: nil, as_fields: ['section.content', 'section.body'], as_models: [] }] + stack = [{ prefix: nil, as_fields: %w[section.content section.body], as_models: [] }] fields = described_class.build_fields_schema(Label, stack) - expect(fields.keys).to eq(['_id', 'name', 'section@@@content', 'section@@@body']) + expect(fields.keys).to eq(%w[_id name section@@@content section@@@body]) end it 'flattens only requested fields' do stack = [{ prefix: nil, as_fields: ['label.name'], as_models: [] }] fields = described_class.build_fields_schema(Band, stack) - expect(fields.keys).to eq(['_id', 'created_at', 'updated_at', 'label', 'label@@@name']) + expect(fields.keys).to eq(%w[_id created_at updated_at label label@@@name]) end it 'onlies flatten selected fields when level = 1' do - stack = [{ prefix: nil, as_fields: ['label.name', 'label.section'], as_models: [] }] + stack = [{ prefix: nil, as_fields: %w[label.name label.section], as_models: [] }] fields = described_class.build_fields_schema(Band, stack) - expect(fields.keys).to eq(['_id', 'created_at', 'updated_at', 'label@@@name', 'label@@@section']) + expect(fields.keys).to eq(%w[_id created_at updated_at label@@@name label@@@section]) end it 'returns a "String" as type when "_id" is generated' do From 13ff085435662ff7cfb2ec9e884530701a6f75ff Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Fri, 28 Feb 2025 10:07:43 +0100 Subject: [PATCH 095/104] chore: fix gemfile-test --- packages/forest_admin_datasource_mongoid/Gemfile-test | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/forest_admin_datasource_mongoid/Gemfile-test b/packages/forest_admin_datasource_mongoid/Gemfile-test index fc57c2e2d..ec10fe024 100644 --- a/packages/forest_admin_datasource_mongoid/Gemfile-test +++ b/packages/forest_admin_datasource_mongoid/Gemfile-test @@ -9,6 +9,7 @@ gem 'mongoid', '~> 9.0' group :development, :test do gem 'forest_admin_agent', path: '../forest_admin_agent' + gem 'forest_admin_datasource_customizer', path: '../forest_admin_datasource_customizer' gem 'forest_admin_datasource_toolkit', path: '../forest_admin_datasource_toolkit' gem 'mongoid-rspec', '~> 4.0' gem 'rails' From db7414a049f6abb2804f6a7075f7a93435d66f4c Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 28 Feb 2025 16:17:07 +0100 Subject: [PATCH 096/104] feat(field): handle validation --- .../parser/validation.rb | 14 ++++++++------ .../utils/schema/fields_generator.rb | 3 ++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/validation.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/validation.rb index e96804cde..10e3aa302 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/validation.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/validation.rb @@ -2,17 +2,19 @@ module ForestAdminDatasourceMongoid module Parser module Validation include ForestAdminDatasourceToolkit::Components::Query::ConditionTree - def get_validations(column) + def get_validations(model, column) + return [] if column.is_a?(Hash) + validations = [] # NOTICE: Do not consider validations if a before_validation Active Records # Callback is detected. default_callback_excluded = [:normalize_changed_in_place_attributes] - return validations if @model._validation_callbacks - .reject { |callback| default_callback_excluded.include?(callback.filter) } - .map(&:kind).include?(:before) + return validations if model._validation_callbacks + .reject { |callback| default_callback_excluded.include?(callback.filter) } + .map(&:kind).include?(:before) - if @model._validators? && @model._validators[column.name.to_sym].size.positive? - @model._validators[column.name.to_sym].each do |validator| + if model._validators? && model._validators[column.name.to_sym].size.positive? + model._validators[column.name.to_sym].each do |validator| # NOTICE: Do not consider conditional validations next if validator.options[:if] || validator.options[:unless] || validator.options[:on] diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb index 7134ed273..6c189da39 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/fields_generator.rb @@ -5,6 +5,7 @@ class FieldsGenerator include ForestAdminDatasourceToolkit::Components::Query::ConditionTree extend Utils::Helpers extend Parser::Column + extend Parser::Validation def self.build_fields_schema(model, stack) our_schema = {} @@ -27,7 +28,7 @@ def self.build_fields_schema(model, stack) is_sortable: get_column_type(field) != 'Json', default_value: default_value, enum_values: [], - validations: [] # get_validations(field) + validations: get_validations(model, field) ) if !field.is_a?(Hash) && field.foreign_key? && field.type != Array && !field.association.polymorphic? From e4f96dbeb859be798c91b888913eb839c699613b Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 28 Feb 2025 16:20:46 +0100 Subject: [PATCH 097/104] chore: add test on field --- .../utils/schema/field_simple_spec.rb | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/schema/field_simple_spec.rb diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/schema/field_simple_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/schema/field_simple_spec.rb new file mode 100644 index 000000000..1560a1421 --- /dev/null +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/schema/field_simple_spec.rb @@ -0,0 +1,159 @@ +require 'spec_helper' + +module ForestAdminDatasourceMongoid + module Utils + module Schema + # rubocop:disable Lint/ConstantDefinitionInBlock + # rubocop:disable RSpec/LeakyConstantDeclaration + describe FieldsGenerator do + let(:stack) { [{ prefix: nil, as_fields: [], as_models: [] }] } + + describe 'when field is required' do + let(:model) do + Class.new do + include Mongoid::Document + field :a_field, type: Integer + validates :a_field, presence: true + end + end + + it 'builds the validation with present operator' do + fields_schema = described_class.build_fields_schema(model, stack) + + expect(fields_schema['a_field'].validations).to eq [{ operator: 'present' }] + end + end + + describe 'when field is not required' do + let(:model) do + Class.new do + include Mongoid::Document + field :a_field, type: Integer + end + end + + it 'does not add a validation' do + fields_schema = described_class.build_fields_schema(model, stack) + + expect(fields_schema['a_field'].validations).to be_empty + end + end + + describe 'when field has a default value' do + let(:model) do + Class.new do + include Mongoid::Document + field :a_field, type: String, default: 'default_value' + end + end + + it 'builds the field schema with a default value' do + fields_schema = described_class.build_fields_schema(model, stack) + + expect(fields_schema['a_field'].default_value).to eq('default_value') + end + end + + describe 'when field is the primary key' do + it 'builds the field schema with a primary key as true' do + fields_schema = described_class.build_fields_schema(Post, stack) + + expect(fields_schema['_id'].is_primary_key).to be(true) + end + end + + describe 'when field is immutable' do + it 'builds the field schema with is_read_only as true' do + class ImmutableFieldModel + include Mongoid::Document + field :a_field, type: Date + attr_readonly :a_field + end + + model = ImmutableFieldModel.new(a_field: Date.today) + model.save! + + model.a_field = Date.tomorrow + expect(model.changed?).to be(false) + expect(model.a_field).to eq(Date.today) + end + end + + describe 'when the enum is not an array of strings' do + let(:model) do + Class.new do + include Mongoid::Document + field :a_field, type: Integer + validates :a_field, inclusion: { in: [1, 2] } + end + end + + it 'raises an error' do + fields_schema = described_class.build_fields_schema(model, stack) + + expect(fields_schema['a_field'].column_type).to eq('Number') + end + end + + describe 'with array fields' do + describe 'with primitive array' do + let(:model) do + Class.new do + include Mongoid::Document + field :a_field, type: Array + end + end + + it 'builds the right column type' do + fields_schema = described_class.build_fields_schema(model, stack) + + expect(fields_schema['a_field'].column_type).to eq('Json') + end + end + + describe 'with object array' do + it 'builds the right schema' do + class ObjectArrayModel + include Mongoid::Document + embeds_many :nested_objects, class_name: 'NestedObject' + end + + class NestedObject + include Mongoid::Document + field :level, type: Integer + end + + fields_schema = described_class.build_fields_schema(ObjectArrayModel, stack) + + expect(fields_schema['nested_objects'].column_type).to eq('[]' => { 'level' => 'Number' }) + end + end + end + + describe 'with an objectId and a ref' do + it 'adds a relation _manyToOne in the fields schema' do + class ManyToOneModel + include Mongoid::Document + belongs_to :company, class_name: 'Company' + end + + class Company + include Mongoid::Document + field :name, type: String + end + + fields_schema = described_class.build_fields_schema(ManyToOneModel, stack) + + expect(fields_schema['company_id__many_to_one']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) + expect(fields_schema['company_id__many_to_one'].foreign_collection).to eq('ForestAdminDatasourceMongoid__Utils__Schema__Company') + expect(fields_schema['company_id__many_to_one'].foreign_key).to eq('company_id') + expect(fields_schema['company_id__many_to_one'].type).to eq('ManyToOne') + expect(fields_schema['company_id__many_to_one'].foreign_key_target).to eq('_id') + end + end + end + # rubocop:enable Lint/ConstantDefinitionInBlock + # rubocop:enable RSpec/LeakyConstantDeclaration + end + end +end From ea8c8ac6eac6ee6c0b13a999d0da91fa7c344e62 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 28 Feb 2025 16:27:27 +0100 Subject: [PATCH 098/104] fix: validation test --- .../parser/validation_spec.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/validation_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/validation_spec.rb index 8d29d7eb0..6f6368a6e 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/validation_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/parser/validation_spec.rb @@ -28,7 +28,7 @@ def some_before_validation_callback end it 'returns empty validations' do - result = collection.get_validations('email') + result = collection.get_validations(model, 'email') expect(result).to eq([]) end end @@ -47,7 +47,7 @@ def some_before_validation_callback it 'returns validations' do email_column = model.fields['email'] - result = collection.get_validations(email_column) + result = collection.get_validations(model, email_column) expect(result).to eq([{ operator: Operators::PRESENT }]) end @@ -68,7 +68,7 @@ def some_before_validation_callback it 'returns a greater_than validation' do age_column = model.fields['age'] - result = collection.get_validations(age_column) + result = collection.get_validations(model, age_column) expect(result).to include({ operator: Operators::GREATER_THAN, value: 20 }) end @@ -88,7 +88,7 @@ def some_before_validation_callback it 'returns a less_than validation' do age_column = model.fields['age'] - result = collection.get_validations(age_column) + result = collection.get_validations(model, age_column) expect(result).to include({ operator: Operators::LESS_THAN, value: 50 }) end @@ -110,7 +110,7 @@ def some_before_validation_callback it 'returns a minimum length validation' do title_column = model.fields['title'] - result = collection.get_validations(title_column) + result = collection.get_validations(model, title_column) expect(result).to include({ operator: Operators::LONGER_THAN, value: 5 }) end @@ -130,7 +130,7 @@ def some_before_validation_callback it 'returns a maximum length validation' do title_column = model.fields['title'] - result = collection.get_validations(title_column) + result = collection.get_validations(model, title_column) expect(result).to include({ operator: Operators::SHORTER_THAN, value: 10 }) end @@ -150,7 +150,7 @@ def some_before_validation_callback it 'returns an exact length validation' do title_column = model.fields['title'] - result = collection.get_validations(title_column) + result = collection.get_validations(model, title_column) expect(result).to include({ operator: Operators::LONGER_THAN, value: 8 }) expect(result).to include({ operator: Operators::SHORTER_THAN, value: 8 }) @@ -173,7 +173,7 @@ def some_before_validation_callback it 'returns a format validation for email' do email_column = model.fields['email'] - result = collection.get_validations(email_column) + result = collection.get_validations(model, email_column) expect(result).to include({ operator: Operators::CONTAINS, value: "/^[a-zA-Z0-9.!\\\#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/" }) end @@ -193,7 +193,7 @@ def some_before_validation_callback it 'returns a custom format validation' do username_column = model.fields['username'] - result = collection.get_validations(username_column) + result = collection.get_validations(model, username_column) expect(result).to include({ operator: Operators::CONTAINS, value: '/^[a-zA-Z0-9_]+$/' }) end From 1a39cba3c42aab246ac184f185162d4ce80bb567 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Mon, 3 Mar 2025 15:52:07 +0100 Subject: [PATCH 099/104] test: add tests on mongoid datasource --- .../collection.rb | 2 +- .../utils/schema/mongoid_schema.rb | 4 +- .../collection_spec.rb | 142 ------------------ .../datasource_spec.rb | 48 +++++- .../utils/pipeline/reparent_generator_spec.rb | 2 + .../spec/spec_helper.rb | 7 + 6 files changed, 60 insertions(+), 145 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index 832ba9b1f..5eedbf876 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -18,7 +18,7 @@ def initialize(datasource, model, stack) @model = model @stack = stack model_name = format_model_name(@model.name) - name = escape(prefix ? "#{prefix}.#{model_name}" : model_name) + name = escape(prefix ? "#{model_name}.#{prefix}" : model_name) super(datasource, name) add_fields(FieldsGenerator.build_fields_schema(model, stack)) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb index 7ca58b2b2..6952746d8 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/utils/schema/mongoid_schema.rb @@ -4,7 +4,7 @@ module Schema class MongoidSchema include ForestAdminDatasourceToolkit::Exceptions include Utils::Helpers - attr_reader :is_array, :is_leaf, :fields + attr_reader :is_array, :is_leaf, :fields, :models def initialize(model, fields, is_array, is_leaf) @models = ObjectSpace.each_object(Class) @@ -100,6 +100,8 @@ def get_sub_schema(path) if child.is_a?(Hash) relation_name = @model.relations[prefix].class_name + raise ForestException, "Collection '#{relation_name}' not found." unless @models.key?(relation_name) + # Traverse arrays if child.is_a?(Hash) && child['[]'] # (has_many embed) diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb index cdd4c6d3d..b2e4e3031 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/collection_spec.rb @@ -54,147 +54,5 @@ module ForestAdminDatasourceMongoid expect(collection.name).to eq('Dummy__MyModel') end - - # it 'add all fields of model to the collection' do - # expect(collection_post.schema[:fields].keys).to include('_id', - # 'created_at', - # 'updated_at', - # 'title', - # 'body', - # 'tag_ids', - # 'comments', - # 'author', - # 'tags') - # end - # - # describe 'fetch_associations' do - # it 'add all relation of model to the collection' do - # expect(collection_post.schema[:fields].keys).to include('comments', 'author', 'tags') - # end - # - # it 'defines the correct association types' do - # expect(collection_post.schema[:fields]['comments']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema) - # expect(collection_post.schema[:fields]['author']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToOneSchema) - # expect(collection_post.schema[:fields]['tags']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema) - # expect(collection_user.schema[:fields]['item']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicManyToOneSchema) - # expect(collection_departure.schema[:fields]['user']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicOneToOneSchema) - # expect(collection_comment.schema[:fields]['post']).to be_a(ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema) - # end - # - # context 'when the relation is embedded' do - # it 'add a single field for an embedded many relation' do - # expect(collection_user.schema[:fields]['addresses']).to be_a(ForestAdminDatasourceToolkit::Schema::ColumnSchema) - # end - # - # it 'add single field with a composite type' do - # expect(collection_band.schema[:fields].keys).to include('label') - # expect(collection_band.schema[:fields]['label'].column_type).to eq( - # { - # '_id' => 'String', - # 'name' => 'String', - # 'section' => { '_id' => 'String', 'content' => 'String', 'body' => 'String' } - # } - # ) - # end - # end - # end - # - # it 'serializes the schema to JSON' do - # json_schema = collection_post.schema.to_json - # expect { JSON.parse(json_schema) }.not_to raise_error - # expect(JSON.parse(json_schema)['fields'].keys).to include('title', 'body', 'comments') - # end - # - # describe '#list' do - # it 'returns a serialized list of records' do - # post = Post.new(title: 'Test', body: 'Body') - # query_result = [post] - # query = instance_double(Utils::Query, get: query_result) - # allow(Utils::Query).to receive_messages(new: query) - # - # result = collection_post.list(nil, Filter.new, Projection.new(%w[_id title body])) - # - # expect(result).to eq([{ '_id' => post._id, 'title' => post.title, 'body' => post.body }]) - # end - # - # it 'returns a serialized list of records with many to one relation' do - # post = Post.new(title: 'Test', body: 'Body') - # comment = Comment.new(name: 'foo', message: 'lorem ipsum', post: post) - # query_result = [comment] - # query = instance_double(Utils::Query, get: query_result) - # allow(Utils::Query).to receive_messages(new: query) - # - # result = collection_comment.list(nil, Filter.new, Projection.new(%w[_id name message post:title])) - # - # expect(result).to eq( - # [ - # { - # '_id' => comment._id, - # 'message' => comment.message, - # 'name' => comment.name, - # 'post' => { 'title' => post.title } - # } - # ] - # ) - # end - # end - # - # describe '#aggregate' do - # it 'call QueryAggregate and return an aggregate result' do - # Post.new(title: 'Test', body: 'Body') - # query = instance_double(Utils::QueryAggregate, get: [{ 'value' => 1 }]) - # allow(Utils::QueryAggregate).to receive_messages(new: query) - # - # filter = Filter.new - # aggregation = Aggregation.new(operation: 'Count') - # result = collection_post.aggregate(nil, filter, aggregation, 10) - # - # expect(Utils::QueryAggregate).to( - # have_received(:new) do |collection, aggregation_request, filter_request, limit| - # expect(collection).to eq(collection_post) - # expect(filter).to eq(filter_request) - # expect(aggregation).to eq(aggregation_request) - # expect(limit).to eq(10) - # end - # ) - # expect(result).to eq([{ 'value' => 1 }]) - # end - # end - # - # describe '#create' do - # it 'call create method of model and return object with full projection' do - # post = Post.new(title: 'Test', body: 'Body') - # model = class_double(Post, create: post) - # allow(collection_post).to receive(:model).and_return(model) - # - # result = collection_post.create(nil, { title: 'Test', body: 'Body' }) - # - # expect(result).to eq({ '_id' => post._id, 'title' => post.title, 'body' => post.body, 'author' => nil }) - # end - # end - # - # describe '#update' do - # it 'call update method of object' do - # model = instance_double(Post, update: true) - # query = instance_double(Utils::Query, build: [model]) - # allow(Utils::Query).to receive_messages(new: query) - # - # collection_post.update(nil, Filter.new, { title: 'new title' }) - # expect(model).to have_received(:update) do |data| - # expect(data).to eq({ title: 'new title' }) - # end - # end - # end - # - # describe '#delete' do - # it 'call destroy method of object' do - # model = instance_double(Post, destroy: true) - # query = instance_double(Utils::Query, build: [model]) - # allow(Utils::Query).to receive_messages(new: query) - # - # collection_post.delete(nil, Filter.new) - # expect(model).to have_received(:destroy) - # end - # end end end diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb index ebcb0cfb3..43844d05c 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/datasource_spec.rb @@ -11,7 +11,7 @@ module ForestAdminDatasourceMongoid end it 'adds collections to the datasource' do - expected = %w[Band Author Tag Comment Departure Post User Team addresses_User CoAuthor Dummy__MyModel] + expected = %w[Band Author Tag Comment Departure Post User Team User_addresses CoAuthor] expect(datasource.collections.keys).to match_array(expected) end @@ -39,5 +39,51 @@ module ForestAdminDatasourceMongoid expect { datasource.add_collection(Collection.new(datasource, Post, [{ prefix: nil, as_fields: [], as_models: [] }])) } .to raise_error(ForestException, '🌳🌳🌳 Collection Post already defined in datasource') end + + describe 'with simple schema' do + it 'give one collection by default' do + class_book = Class.new { include Mongoid::Document } + stub_const('Dummy::Book', class_book) + + allow(ObjectSpace).to receive(:each_object).and_return([Dummy::Book]) + datasource = described_class.new + + expect(datasource.collections.keys).to eq(['Dummy__Book']) + end + + it 'accept both dots and colons as separator in options' do + class_book = Class.new do + include Mongoid::Document + embeds_one :author, class_name: 'Dummy::Author' + end + class_author = Class.new do + include Mongoid::Document + field :first_name, type: String + field :last_name, type: String + end + stub_const('Dummy::Book', class_book) + stub_const('Dummy::Author', class_author) + + allow(ObjectSpace).to receive(:each_object).and_return([Dummy::Book, Dummy::Author]) + datasource = described_class.new( + options: { flatten_mode: 'manual', flatten_options: { 'Dummy::Book' => { as_models: %w[author.first_name author:last_name] } } } + ) + + expect(datasource.collections.keys).to eq(%w[Dummy__Book Dummy__Book_author_first_name Dummy__Book_author_last_name Dummy__Author]) + end + end + + describe 'with schema that contains references to unknown model' do + it 'raise an error' do + class_book = Class.new do + include Mongoid::Document + embeds_one :author + end + stub_const('Dummy::Book', class_book) + allow(ObjectSpace).to receive(:each_object).and_return([Dummy::Book]) + + expect { described_class.new }.to raise_error(ForestException, "🌳🌳🌳 Collection 'Author' not found.") + end + end end end diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator_spec.rb index ae559feed..9a9831c92 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/pipeline/reparent_generator_spec.rb @@ -41,6 +41,8 @@ module Pipeline stub_const('Dummy::Author', author_model) stub_const('Dummy::Edition', edition_model) stub_const('Dummy::Book', model) + + allow(ObjectSpace).to receive(:each_object).and_return([Dummy::Author, Dummy::Edition, Dummy::Book]) end after do diff --git a/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb b/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb index 9b6485430..d22ba39b3 100644 --- a/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb +++ b/packages/forest_admin_datasource_mongoid/spec/spec_helper.rb @@ -28,6 +28,13 @@ RSpec.configure do |config| config.include Mongoid::Matchers, type: :model + config.before do + models_to_load = Dir.glob(File.join('spec', 'dummy', 'app', 'models', '**', '*.rb')) + .collect { |file_path| File.basename(file_path, '.rb').camelize.constantize } + + allow(ObjectSpace).to receive(:each_object).and_return(models_to_load) + end + config.before(:suite) do Mongoid.load!(File.expand_path('dummy/config/mongoid.yml', __dir__), :test) end From bfdd929e3399be785619cff39ab91e30be66e35e Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Tue, 4 Mar 2025 12:02:14 +0100 Subject: [PATCH 100/104] fix: get_column_type --- .../parser/column.rb | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb index 58b941e53..0e991c367 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/parser/column.rb @@ -26,19 +26,14 @@ module Column }.freeze def get_column_type(column) - if column.is_a? Mongoid::Fields::Standard + case column + when Mongoid::Fields::Standard return TYPES[column.type.to_s] || 'String' - elsif column.is_a? Mongoid::Fields::ForeignKey + when Mongoid::Fields::ForeignKey return 'String' - elsif column.respond_to?(:embedded?) && column.embedded? - model = column.class_name.constantize - fields = model.fields.merge(get_embedded_fields(model)) - type = fields.reduce({}) do |memo, (name, column)| - memo.merge({ name => get_column_type(column) }) - end + when Hash + return [get_column_type(column['[]'])] if column.key?('[]') - return column.is_a?(Mongoid::Association::Embedded::EmbedsMany) ? [type] : type - elsif column.is_a? Hash return column.reduce({}) do |memo, (name, sub_column)| memo.merge({ name => get_column_type(sub_column) }) end From 737497e40d71f496cec7f9b9c2a672348a01665e Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Tue, 4 Mar 2025 17:00:25 +0100 Subject: [PATCH 101/104] fix: routes declarations --- packages/forest_admin_rails/config/routes.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/forest_admin_rails/config/routes.rb b/packages/forest_admin_rails/config/routes.rb index 26af32b76..08d04e5d3 100644 --- a/packages/forest_admin_rails/config/routes.rb +++ b/packages/forest_admin_rails/config/routes.rb @@ -7,7 +7,8 @@ to: 'forest#index', via: agent_route[:method], as: name, - route_alias: name + route_alias: name, + constraints: { id: /[a-zA-Z0-9\.]*+/ } end end end From 3a29b2913333b66920763086d7a850250447055c Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Tue, 4 Mar 2025 17:01:32 +0100 Subject: [PATCH 102/104] refactor: remove unused code --- .../collection.rb | 169 +----------------- 1 file changed, 7 insertions(+), 162 deletions(-) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index 5eedbf876..66fad98e8 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -22,15 +22,12 @@ def initialize(datasource, model, stack) super(datasource, name) add_fields(FieldsGenerator.build_fields_schema(model, stack)) - # fetch_fields - # fetch_associations enable_count end def list(_caller, filter, projection) projection = projection.union(filter.condition_tree&.projection || [], filter.sort&.projection || []) pipeline = [*build_base_pipeline(filter, projection), *ProjectionGenerator.project(projection)] - add_null_values(replace_mongo_types(model.unscoped.collection.aggregate(pipeline).to_a), projection) end @@ -54,21 +51,9 @@ def create(caller, data) def _create(_caller, flat_data) as_fields = @stack[stack.length - 1][:as_fields] data = unflatten_record(flat_data, as_fields) + inserted_record = @model.create(data) - if @stack.length < 2 - inserted_record = @model.create(data) - - return { '_id' => inserted_record.attributes['_id'], **flat_data } - end - - # Only array fields can create subdocuments (the others should use update) - schema = MongoidSchema.from_model(@model).apply_stack(@stack) - - if schema.is_array - create_for_array_subfield(data, flat_data, schema) - else - create_for_object_subfield(data, flat_data) - end + { '_id' => inserted_record.attributes['_id'], **flat_data } end def update(caller, filter, data) @@ -83,16 +68,11 @@ def _update(_caller, filter, flat_patch) records = list(nil, filter, Projection.new(['_id'])) ids = records.map { |record| record['_id'] } - if @stack.length < 2 - if ids.length > 1 - @model.where(_id: ids).update_all(formatted_patch) - else - @model.find(ids.first).update(formatted_patch) - end + if ids.length > 1 + @model.where(_id: ids).update_all(formatted_patch) + else + @model.find(ids.first).update(formatted_patch) end - - # TODO: Other cases - nil end def delete(caller, filter) @@ -103,12 +83,7 @@ def _delete(_caller, filter) records = list(nil, filter, Projection.new(['_id'])) ids = records.map { |record| record['_id'] } - @model.where(_id: ids).delete_all if @stack.length < 2 - - # TODO: Other cases - # schema = MongoidSchema.from_model(@model).apply_stack(@stack) - # ids_by_path = group_ids_by_path(ids) - nil + @model.where(_id: ids).delete_all end private @@ -147,140 +122,10 @@ def build_base_pipeline(filter, projection) ] end - def format_model_name(class_name) - class_name.gsub('::', '__') - end - - def create_for_array_subfield(data, flat_data, schema) - # TODO - end - - def create_for_object_subfield(data, _flat_data) - # TODO - raise ValidationError, 'Trying to create multiple subrecords at once' if data.length > 1 - raise ValidationError, 'Trying to create without data' if data.empty? - end - def handle_validation_error yield rescue Mongoid::Errors::Validations => e raise ForestAdminDatasourceToolkit::Exceptions::ValidationError, e.message end - - # def fetch_fields - # # Standard fields - # @model.fields.each do |column_name, column| - # field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( - # column_type: get_column_type(column), - # filter_operators: operators_for_column_type(get_column_type(column)), - # is_primary_key: column.object_id_field? && column.association.nil?, - # is_read_only: false, - # is_sortable: true, - # default_value: column.object_id_field? ? nil : get_default_value(column), - # enum_values: [], - # validations: get_validations(column) - # ) - # - # add_field(column_name, field) - # end - # - # # Embedded field (EmbedsMany and EmbedsOne) - # get_embedded_fields(@model).each do |column_name, column| - # field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new( - # column_type: get_column_type(column), - # is_primary_key: false, - # is_sortable: false - # ) - # add_field(column_name, field) - # end - # end - - # def fetch_associations - # @model.relations.transform_values do |association| - # case association - # when Mongoid::Association::Referenced::HasMany - # if association.polymorphic? - # add_field( - # association.name.to_s, - # ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicOneToManySchema.new( - # foreign_collection: format_model_name(association.klass.name), - # origin_key: association.foreign_key, - # origin_key_target: association.primary_key, - # origin_type_field: association.type, - # origin_type_value: association.inverse_class_name.constantize - # ) - # ) - # else - # add_field( - # association.name.to_s, - # ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema.new( - # foreign_collection: format_model_name(association.klass.name), - # origin_key: association.foreign_key, - # origin_key_target: association.primary_key - # ) - # ) - # end - # when Mongoid::Association::Referenced::BelongsTo - # if association.polymorphic? - # foreign_collections = get_polymorphic_types(association.name) - # add_field( - # association.name.to_s, - # ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicManyToOneSchema.new( - # foreign_collections: foreign_collections.keys, - # foreign_key: association.foreign_key, - # foreign_key_type_field: association.inverse_type, - # foreign_key_targets: foreign_collections - # ) - # ) - # schema[:fields][association.foreign_key].is_read_only = true - # schema[:fields][association.inverse_type].is_read_only = true - # else - # add_field( - # association.name.to_s, - # ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema.new( - # foreign_collection: format_model_name(association.klass.name), - # foreign_key: association.foreign_key, - # foreign_key_target: association.primary_key - # ) - # ) - # end - # when Mongoid::Association::Referenced::HasOne - # if association.polymorphic? - # add_field( - # association.name.to_s, - # ForestAdminDatasourceToolkit::Schema::Relations::PolymorphicOneToOneSchema.new( - # foreign_collection: format_model_name(association.klass.name), - # origin_key: association.foreign_key, - # origin_key_target: association.primary_key, - # origin_type_field: association.type, - # origin_type_value: association.inverse_class_name.constantize - # ) - # ) - # else - # add_field( - # association.name.to_s, - # ForestAdminDatasourceToolkit::Schema::Relations::OneToOneSchema.new( - # foreign_collection: format_model_name(association.klass.name), - # origin_key: association.foreign_key, - # origin_key_target: association.primary_key - # ) - # ) - # end - # when Mongoid::Association::Referenced::HasAndBelongsToMany - # foreign_key_of_association = association.klass.reflect_on_all_associations.find do |assoc| - # assoc.klass == association.inverse_class_name.constantize - # end&.foreign_key - # - # add_field( - # association.name.to_s, - # ForestAdminDatasourceToolkit::Schema::Relations::OneToManySchema.new( - # foreign_collection: format_model_name(association.klass.name), - # origin_key: foreign_key_of_association, - # origin_key_target: association.primary_key - # ) - # ) - # end - # end - # end end end From 20d0c395bfedc80da21b5e2939d40417c90c5577 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Thu, 6 Mar 2025 11:18:18 +0100 Subject: [PATCH 103/104] fix: rollback delete methode format_model_name --- .../lib/forest_admin_datasource_mongoid/collection.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb index 66fad98e8..f9e2601da 100644 --- a/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb +++ b/packages/forest_admin_datasource_mongoid/lib/forest_admin_datasource_mongoid/collection.rb @@ -122,6 +122,10 @@ def build_base_pipeline(filter, projection) ] end + def format_model_name(class_name) + class_name.gsub('::', '__') + end + def handle_validation_error yield rescue Mongoid::Errors::Validations => e From d04b15b15e4731fe0bd481ef13bf57735bc5a8a8 Mon Sep 17 00:00:00 2001 From: Nicolas Alexandre Date: Mon, 10 Mar 2025 10:07:41 +0100 Subject: [PATCH 104/104] fix: test --- .../utils/schema/field_simple_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/schema/field_simple_spec.rb b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/schema/field_simple_spec.rb index 1560a1421..614d12f88 100644 --- a/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/schema/field_simple_spec.rb +++ b/packages/forest_admin_datasource_mongoid/spec/lib/forest_admin_datasource_mongoid/utils/schema/field_simple_spec.rb @@ -125,7 +125,7 @@ class NestedObject fields_schema = described_class.build_fields_schema(ObjectArrayModel, stack) - expect(fields_schema['nested_objects'].column_type).to eq('[]' => { 'level' => 'Number' }) + expect(fields_schema['nested_objects'].column_type).to eq([{ 'level' => 'Number' }]) end end end