From 48c43777750049308800640264ae9578abeae2c0 Mon Sep 17 00:00:00 2001 From: Chris Truter <crisptrutski@users.noreply.github.com> Date: Mon, 12 Feb 2024 10:59:35 +0200 Subject: [PATCH] Revert breaking change to the `active-tables` driver method (#38562) --- docs/developers-guide/driver-changelog.md | 10 +-- .../redshift/src/metabase/driver/redshift.clj | 20 +++--- .../test/metabase/driver/redshift_test.clj | 2 +- src/metabase/driver.clj | 6 +- src/metabase/driver/mysql.clj | 50 ++++++++------- src/metabase/driver/postgres.clj | 63 +++++++++---------- src/metabase/driver/sql_jdbc.clj | 10 ++- src/metabase/driver/sql_jdbc/sync.clj | 1 + .../sql_jdbc/sync/describe_database.clj | 26 ++++---- .../driver/sql_jdbc/sync/interface.clj | 23 ++++++- test/metabase/driver/mysql_test.clj | 4 +- test/metabase/driver/postgres_test.clj | 2 +- .../sql_jdbc/sync/describe_database_test.clj | 4 +- 13 files changed, 124 insertions(+), 97 deletions(-) diff --git a/docs/developers-guide/driver-changelog.md b/docs/developers-guide/driver-changelog.md index 974034a1e26..0631f0b1146 100644 --- a/docs/developers-guide/driver-changelog.md +++ b/docs/developers-guide/driver-changelog.md @@ -6,6 +6,11 @@ title: Driver interface changelog ## Metabase 0.49.0 +- The multimethod `metabase.driver.sql-jdbc.sync.interface/current-user-table-privileges` has been added. + JDBC-based drivers can implement this to improve the performance of the default SQL JDBC implementation of + `metabase.driver/describe-database`. It needs to be implemented if the database supports the `:table-privileges` + feature and the driver is JDBC-based. + - The multimethod `metabase.driver/create-table!` can take an additional optional map with an optional key `primary-key`. `metabase.driver/upload-type->database-type` must also be changed, so that if `:metabase.upload/auto-incrementing-int-pk` is provided as the `upload-type` argument, the function should return a @@ -26,16 +31,11 @@ title: Driver interface changelog `metabase.driver.sql.query-processor/honey-sql-version` is now deprecated and no longer called. All drivers are assumed to use Honey SQL 2. -- The method `metabase.driver.sql-jdbc.sync.interface/active-tables` that we added in 47 has been updated to require - an additional argument: `database`. - The new function arglist is `[driver database connection schema-inclusion-filters schema-exclusion-filters]`. - - The method `metabase.driver.sql.parameters.substitution/align-temporal-unit-with-param-type` is now deprecated. Use `metabase.driver.sql.parameters.substitution/align-temporal-unit-with-param-type-and-value` instead, which has access to `value` and therefore provides more flexibility for choosing the right conversion unit. ## Metabase 0.48.0 - - The MBQL schema in `metabase.mbql.schema` now uses [Malli](https://github.com/metosin/malli) instead of [Schema](https://github.com/plumatic/schema). If you were using this namespace in combination with Schema, you'll want to update your code to use Malli instead. diff --git a/modules/drivers/redshift/src/metabase/driver/redshift.clj b/modules/drivers/redshift/src/metabase/driver/redshift.clj index 6228751cd56..71590c5c3fb 100644 --- a/modules/drivers/redshift/src/metabase/driver/redshift.clj +++ b/modules/drivers/redshift/src/metabase/driver/redshift.clj @@ -12,8 +12,7 @@ [metabase.driver.sql-jdbc.execute :as sql-jdbc.execute] [metabase.driver.sql-jdbc.execute.legacy-impl :as sql-jdbc.legacy] [metabase.driver.sql-jdbc.sync :as sql-jdbc.sync] - [metabase.driver.sql-jdbc.sync.describe-table - :as sql-jdbc.describe-table] + [metabase.driver.sql-jdbc.sync.describe-table :as sql-jdbc.describe-table] [metabase.driver.sql.query-processor :as sql.qp] [metabase.lib.metadata :as lib.metadata] [metabase.mbql.util :as mbql.u] @@ -399,14 +398,13 @@ [driver db-id table-name column-names values] ((get-method driver/insert-into! :sql-jdbc) driver db-id table-name column-names values)) -(defmethod driver/current-user-table-privileges :redshift - [_driver database] - (let [conn-spec (sql-jdbc.conn/db->pooled-connection-spec database)] - ;; KNOWN LIMITATION: this won't return privileges for external tables, calling has_table_privilege on an external table - ;; result in an operation not supported error - (->> (jdbc/query - conn-spec - (str/join +(defmethod sql-jdbc.sync/current-user-table-privileges :redshift + [_driver conn-spec & {:as _options}] + ;; KNOWN LIMITATION: this won't return privileges for external tables, calling has_table_privilege on an external table + ;; result in an operation not supported error + (->> (jdbc/query + conn-spec + (str/join "\n" ["with table_privileges as (" " select" @@ -428,7 +426,7 @@ ")" "select t.*" "from table_privileges t"])) - (filter #(or (:select %) (:update %) (:delete %) (:update %)))))) + (filter #(or (:select %) (:update %) (:delete %) (:update %))))) ;;; ----------------------------------------------- Connection Impersonation ------------------------------------------ diff --git a/modules/drivers/redshift/test/metabase/driver/redshift_test.clj b/modules/drivers/redshift/test/metabase/driver/redshift_test.clj index 20c3d264c0f..02dcc585ccf 100644 --- a/modules/drivers/redshift/test/metabase/driver/redshift_test.clj +++ b/modules/drivers/redshift/test/metabase/driver/redshift_test.clj @@ -421,7 +421,7 @@ (sql-jdbc.conn/with-connection-spec-for-testing-connection [spec [:redshift (assoc (:details (mt/db)) :user username)]] (with-redefs [sql-jdbc.conn/db->pooled-connection-spec (fn [_] spec)] - (set (driver/current-user-table-privileges driver/*driver* (mt/db))))))] + (set (sql-jdbc.sync/current-user-table-privileges driver/*driver* spec)))))] (try (execute! (format (str diff --git a/src/metabase/driver.clj b/src/metabase/driver.clj index 3bcd1cbda62..878a874b823 100644 --- a/src/metabase/driver.clj +++ b/src/metabase/driver.clj @@ -968,7 +968,7 @@ (defmethod create-auto-pk-with-append-csv? ::driver [_] false) (defmulti current-user-table-privileges - "Returns the rows of data as arrays needed to populate the tabel_privileges table + "Returns the rows of data as arrays needed to populate the table_privileges table with the DB connection's current user privileges. The data contains the privileges that the user has on the given `database`. The privileges include select, insert, update, and delete. @@ -984,7 +984,7 @@ Either: (1) role is null, corresponding to the privileges of the DB connection's current user - (2) role is not null, corresponing to the privileges of the role" - {:added "0.48.0", :arglists '([driver database])} + (2) role is not null, corresponding to the privileges of the role" + {:added "0.48.0", :arglists '([driver database & args])} dispatch-on-initialized-driver :hierarchy #'hierarchy) diff --git a/src/metabase/driver/mysql.clj b/src/metabase/driver/mysql.clj index 98bd19d1025..6048e8423c9 100644 --- a/src/metabase/driver/mysql.clj +++ b/src/metabase/driver/mysql.clj @@ -86,6 +86,11 @@ [database] (-> database :dbms_version :flavor (= "MariaDB"))) +(defn mariadb-connection? + "Returns true if the database is MariaDB." + [driver conn] + (->> conn (sql-jdbc.sync/dbms-version driver) :flavor (= "MariaDB"))) + (defmethod driver/database-supports? [:mysql :table-privileges] [_driver _feat _db] ;; Disabled completely due to errors when dealing with partial revokes (metabase#38499) @@ -134,6 +139,28 @@ (warn-on-unsupported-versions driver details) true)) +(declare table-names->privileges) +(declare privilege-grants-for-user) + +(defmethod sql-jdbc.sync/current-user-table-privileges :mysql + [driver conn & {:as _options}] + ;; MariaDB doesn't allow users to query the privileges of roles a user might have (unless they have select privileges + ;; for the mysql database), so we can't query the full privileges of the current user. + (when-not (mariadb-connection? driver conn) + (let [sql->tuples (fn [sql] (drop 1 (jdbc/query conn sql {:as-arrays? true}))) + db-name (ffirst (sql->tuples "SELECT DATABASE()")) + table-names (map first (sql->tuples "SHOW TABLES"))] + (for [[table-name privileges] (table-names->privileges (privilege-grants-for-user conn "CURRENT_USER()") + db-name + table-names)] + {:role nil + :schema nil + :table table-name + :select (contains? privileges :select) + :update (contains? privileges :update) + :insert (contains? privileges :insert) + :delete (contains? privileges :delete)})))) + (def default-ssl-cert-details "Server SSL certificate chain, in PEM format." {:name "ssl-cert" @@ -873,26 +900,3 @@ (when-let [privileges (not-empty (set/union all-table-privileges (get table-privileges table-name)))] [table-name privileges]))) table-names))) - -(defmethod driver/current-user-table-privileges :mysql - [_driver database] - ;; MariaDB doesn't allow users to query the privileges of roles a user might have (unless they have select privileges - ;; for the mysql database), so we can't query the full privileges of the current user. - (when-not (mariadb? database) - (let [conn-spec (sql-jdbc.conn/db->pooled-connection-spec database) - db-name (or (get-in database [:details :db]) - ;; some tests are stil using dbname - (get-in database [:details :dbname])) - table-names (->> (jdbc/query conn-spec "SHOW TABLES" {:as-arrays? true}) - (drop 1) - (map first))] - (for [[table-name privileges] (table-names->privileges (privilege-grants-for-user conn-spec "CURRENT_USER()") - db-name - table-names)] - {:role nil - :schema nil - :table table-name - :select (contains? privileges :select) - :update (contains? privileges :update) - :insert (contains? privileges :insert) - :delete (contains? privileges :delete)})))) diff --git a/src/metabase/driver/postgres.clj b/src/metabase/driver/postgres.clj index cb445655a19..311fb2360d1 100644 --- a/src/metabase/driver/postgres.clj +++ b/src/metabase/driver/postgres.clj @@ -847,38 +847,37 @@ (StringReader.))] (.copyIn copy-manager ^String sql tsvs)))))) -(defmethod driver/current-user-table-privileges :postgres - [_driver database] - (let [conn-spec (sql-jdbc.conn/db->pooled-connection-spec database)] - ;; KNOWN LIMITATION: this won't return privileges for foreign tables, calling has_table_privilege on a foreign table - ;; result in a operation not supported error - (->> (jdbc/query - conn-spec - (str/join - "\n" - ["with table_privileges as (" - " select" - " NULL as role," - " t.schemaname as schema," - " t.objectname as table," - " pg_catalog.has_table_privilege(current_user, '\"' || t.schemaname || '\"' || '.' || '\"' || t.objectname || '\"', 'UPDATE') as update," - " pg_catalog.has_table_privilege(current_user, '\"' || t.schemaname || '\"' || '.' || '\"' || t.objectname || '\"', 'SELECT') as select," - " pg_catalog.has_table_privilege(current_user, '\"' || t.schemaname || '\"' || '.' || '\"' || t.objectname || '\"', 'INSERT') as insert," - " pg_catalog.has_table_privilege(current_user, '\"' || t.schemaname || '\"' || '.' || '\"' || t.objectname || '\"', 'DELETE') as delete" - " from (" - " select schemaname, tablename as objectname from pg_catalog.pg_tables" - " union" - " select schemaname, viewname as objectname from pg_catalog.pg_views" - " union" - " select schemaname, matviewname as objectname from pg_catalog.pg_matviews" - " ) t" - " where t.schemaname !~ '^pg_'" - " and t.schemaname <> 'information_schema'" - " and pg_catalog.has_schema_privilege(current_user, t.schemaname, 'USAGE')" - ")" - "select t.*" - "from table_privileges t"])) - (filter #(or (:select %) (:update %) (:delete %) (:update %)))))) +(defmethod sql-jdbc.sync/current-user-table-privileges :postgres + [_driver conn-spec & {:as _options}] + ;; KNOWN LIMITATION: this won't return privileges for foreign tables, calling has_table_privilege on a foreign table + ;; result in a operation not supported error + (->> (jdbc/query + conn-spec + (str/join + "\n" + ["with table_privileges as (" + " select" + " NULL as role," + " t.schemaname as schema," + " t.objectname as table," + " pg_catalog.has_table_privilege(current_user, '\"' || t.schemaname || '\"' || '.' || '\"' || t.objectname || '\"', 'update') as update," + " pg_catalog.has_table_privilege(current_user, '\"' || t.schemaname || '\"' || '.' || '\"' || t.objectname || '\"', 'select') as select," + " pg_catalog.has_table_privilege(current_user, '\"' || t.schemaname || '\"' || '.' || '\"' || t.objectname || '\"', 'insert') as insert," + " pg_catalog.has_table_privilege(current_user, '\"' || t.schemaname || '\"' || '.' || '\"' || t.objectname || '\"', 'delete') as delete" + " from (" + " select schemaname, tablename as objectname from pg_catalog.pg_tables" + " union" + " select schemaname, viewname as objectname from pg_catalog.pg_views" + " union" + " select schemaname, matviewname as objectname from pg_catalog.pg_matviews" + " ) t" + " where t.schemaname !~ '^pg_'" + " and t.schemaname <> 'information_schema'" + " and pg_catalog.has_schema_privilege(current_user, t.schemaname, 'usage')" + ")" + "select t.*" + "from table_privileges t"])) + (filter #(or (:select %) (:update %) (:delete %) (:update %))))) ;;; ------------------------------------------------- User Impersonation -------------------------------------------------- diff --git a/src/metabase/driver/sql_jdbc.clj b/src/metabase/driver/sql_jdbc.clj index 4f454231fc4..97676cf3ac7 100644 --- a/src/metabase/driver/sql_jdbc.clj +++ b/src/metabase/driver/sql_jdbc.clj @@ -9,7 +9,6 @@ [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn] [metabase.driver.sql-jdbc.execute :as sql-jdbc.execute] [metabase.driver.sql-jdbc.sync :as sql-jdbc.sync] - [metabase.driver.sql-jdbc.sync.interface :as sql-jdbc.sync.interface] [metabase.driver.sql.query-processor :as sql.qp] [metabase.driver.sync :as driver.s] [metabase.query-processor.writeback :as qp.writeback] @@ -186,10 +185,17 @@ (fn [^java.sql.Connection conn] (let [[inclusion-patterns exclusion-patterns] (driver.s/db-details->schema-filter-patterns database)] - (into #{} (sql-jdbc.sync.interface/filtered-syncable-schemas driver conn (.getMetaData conn) inclusion-patterns exclusion-patterns)))))) + (into #{} (sql-jdbc.sync/filtered-syncable-schemas driver conn (.getMetaData conn) inclusion-patterns exclusion-patterns)))))) (defmethod driver/set-role! :sql-jdbc [driver conn role] (let [sql (driver.sql/set-role-statement driver role)] (with-open [stmt (.createStatement ^Connection conn)] (.execute stmt sql)))) + +(defmethod driver/current-user-table-privileges :sql-jdbc + [driver database & {:as args}] + (sql-jdbc.sync/current-user-table-privileges + driver + (sql-jdbc.conn/db->pooled-connection-spec database) + args)) diff --git a/src/metabase/driver/sql_jdbc/sync.clj b/src/metabase/driver/sql_jdbc/sync.clj index 1665ff31570..41748aa74c9 100644 --- a/src/metabase/driver/sql_jdbc/sync.clj +++ b/src/metabase/driver/sql_jdbc/sync.clj @@ -16,6 +16,7 @@ [sql-jdbc.sync.interface active-tables column->semantic-type + current-user-table-privileges database-type->base-type db-default-timezone describe-nested-field-columns diff --git a/src/metabase/driver/sql_jdbc/sync/describe_database.clj b/src/metabase/driver/sql_jdbc/sync/describe_database.clj index 0f95d11476c..7a26f7f72a2 100644 --- a/src/metabase/driver/sql_jdbc/sync/describe_database.clj +++ b/src/metabase/driver/sql_jdbc/sync/describe_database.clj @@ -115,8 +115,8 @@ :type (.getString rset "TABLE_TYPE")})))))) (defn- schema+table-with-select-privileges - [driver database] - (->> (driver/current-user-table-privileges driver database) + [driver conn] + (->> (sql-jdbc.sync.interface/current-user-table-privileges driver {:connection conn}) (filter #(true? (:select %))) (map (fn [{:keys [schema table]}] [schema table])) @@ -130,11 +130,11 @@ (let [have-select-privilege-fn* (have-select-privilege-fn driver database conn) tables ...] (filter have-select-privilege-fn* tables))" - [driver database conn] + [driver conn] ;; `sql-jdbc.sync.interface/have-select-privilege?` is slow because we're doing a SELECT query on each table ;; It's basically a N+1 operation where N is the number of tables in the database - (if (driver/database-supports? driver :table-privileges database) - (let [schema+table-with-select-privileges (schema+table-with-select-privileges driver database)] + (if (driver/database-supports? driver :table-privileges nil) + (let [schema+table-with-select-privileges (schema+table-with-select-privileges driver conn)] (fn [{schema :schema table :name ttype :type}] ;; driver/current-user-table-privileges does not return privileges for external table on redshift, and foreign ;; table on postgres, so we need to use the select method on them @@ -151,27 +151,27 @@ This is as much as 15x faster for Databases with lots of system tables than `post-filtered-active-tables` (4 seconds vs 60)." - [driver database ^Connection conn & [db-name-or-nil schema-inclusion-filters schema-exclusion-filters]] + [driver ^Connection conn & [db-name-or-nil schema-inclusion-filters schema-exclusion-filters]] {:pre [(instance? Connection conn)]} (let [metadata (.getMetaData conn) syncable-schemas (sql-jdbc.sync.interface/filtered-syncable-schemas driver conn metadata schema-inclusion-filters schema-exclusion-filters) - have-select-privilege-fn? (have-select-privilege-fn driver database conn)] + have-select-privilege-fn? (have-select-privilege-fn driver conn)] (eduction (mapcat (fn [schema] (->> (db-tables driver metadata schema db-name-or-nil) (filter have-select-privilege-fn?) (map #(dissoc % :type))))) syncable-schemas))) (defmethod sql-jdbc.sync.interface/active-tables :sql-jdbc - [driver database connection schema-inclusion-filters schema-exclusion-filters] - (fast-active-tables driver database connection nil schema-inclusion-filters schema-exclusion-filters)) + [driver connection schema-inclusion-filters schema-exclusion-filters] + (fast-active-tables driver connection nil schema-inclusion-filters schema-exclusion-filters)) (defn post-filtered-active-tables "Alternative implementation of `active-tables` best suited for DBs with little or no support for schemas. Fetch *all* Tables, then filter out ones whose schema is in `excluded-schemas` Clojure-side." - [driver database ^Connection conn & [db-name-or-nil schema-inclusion-filters schema-exclusion-filters]] + [driver ^Connection conn & [db-name-or-nil schema-inclusion-filters schema-exclusion-filters]] {:pre [(instance? Connection conn)]} - (let [have-select-privilege-fn? (have-select-privilege-fn driver database conn)] + (let [have-select-privilege-fn? (have-select-privilege-fn driver conn)] (eduction (comp (filter (let [excluded (sql-jdbc.sync.interface/excluded-schemas driver)] @@ -206,7 +206,7 @@ (let [schema-filter-prop (driver.u/find-schema-filters-prop driver) has-schema-filter-prop? (some? schema-filter-prop) database (db-or-id-or-spec->database db-or-id-or-spec) - default-active-tbl-fn #(into #{} (sql-jdbc.sync.interface/active-tables driver database conn nil nil))] + default-active-tbl-fn #(into #{} (sql-jdbc.sync.interface/active-tables driver conn nil nil))] (if has-schema-filter-prop? ;; TODO: the else of this branch seems uncessary, why do you want to call describe-database on a database that ;; does not exists? @@ -215,6 +215,6 @@ [inclusion-patterns exclusion-patterns] (driver.s/db-details->schema-filter-patterns prop-nm database)] - (into #{} (sql-jdbc.sync.interface/active-tables driver database conn inclusion-patterns exclusion-patterns))) + (into #{} (sql-jdbc.sync.interface/active-tables driver conn inclusion-patterns exclusion-patterns))) (default-active-tbl-fn)) (default-active-tbl-fn)))))}) diff --git a/src/metabase/driver/sql_jdbc/sync/interface.clj b/src/metabase/driver/sql_jdbc/sync/interface.clj index 9b2c684658e..fb33c41dd83 100644 --- a/src/metabase/driver/sql_jdbc/sync/interface.clj +++ b/src/metabase/driver/sql_jdbc/sync/interface.clj @@ -12,7 +12,6 @@ functions for more details on the differences." {:added "0.37.1" :arglists '([driver - database ^java.sql.Connection connection ^String schema-inclusion-filters ^String schema-exclusion-filters])} @@ -103,3 +102,25 @@ {:added "0.43.0", :arglists '([driver database table])} driver/dispatch-on-initialized-driver :hierarchy #'driver/hierarchy) + +(defmulti current-user-table-privileges + "Returns the rows of data as arrays needed to populate the table_privileges table + with the DB connection's current user privileges. + The data contains the privileges that the user has on the given `database`. + The privileges include select, insert, update, and delete. + + The rows have the following keys and value types: + - role :- [:maybe :string] + - schema :- [:maybe :string] + - table :- :string + - select :- :boolean + - update :- :boolean + - insert :- :boolean + - delete :- :boolean + + Either: + (1) role is null, corresponding to the privileges of the DB connection's current user + (2) role is not null, corresponding to the privileges of the role" + {:added "0.49.0" :arglists '([driver conn-spec & args])} + driver/dispatch-on-initialized-driver + :hierarchy #'driver/hierarchy) diff --git a/test/metabase/driver/mysql_test.clj b/test/metabase/driver/mysql_test.clj index 30f256b45c1..a7e4dbb0b7a 100644 --- a/test/metabase/driver/mysql_test.clj +++ b/test/metabase/driver/mysql_test.clj @@ -697,9 +697,7 @@ (sql-jdbc.conn/with-connection-spec-for-testing-connection [spec [:mysql new-connection-details]] (with-redefs [sql-jdbc.conn/db->pooled-connection-spec (fn [_] spec)] - (driver/current-user-table-privileges driver/*driver* - (assoc (mt/db) :name "test table privileges db" - :details new-connection-details))))))] + (sql-jdbc.sync/current-user-table-privileges driver/*driver* spec {})))))] (try (doseq [stmt ["CREATE TABLE `bar` (id INTEGER);" "CREATE TABLE `baz` (id INTEGER);" diff --git a/test/metabase/driver/postgres_test.clj b/test/metabase/driver/postgres_test.clj index b6eda280ce7..6bddba63fe1 100644 --- a/test/metabase/driver/postgres_test.clj +++ b/test/metabase/driver/postgres_test.clj @@ -1240,7 +1240,7 @@ (sql-jdbc.conn/with-connection-spec-for-testing-connection [spec [:postgres (assoc (:details (mt/db)) :user "privilege_rows_test_example_role")]] (with-redefs [sql-jdbc.conn/db->pooled-connection-spec (fn [_] spec)] - (set (driver/current-user-table-privileges driver/*driver* (mt/db))))))] + (set (sql-jdbc.sync/current-user-table-privileges driver/*driver* spec)))))] (try (jdbc/execute! conn-spec (str "DROP SCHEMA IF EXISTS \"dotted.schema\" CASCADE;" diff --git a/test/metabase/driver/sql_jdbc/sync/describe_database_test.clj b/test/metabase/driver/sql_jdbc/sync/describe_database_test.clj index 3365551792f..0025b6b1d7a 100644 --- a/test/metabase/driver/sql_jdbc/sync/describe_database_test.clj +++ b/test/metabase/driver/sql_jdbc/sync/describe_database_test.clj @@ -59,7 +59,7 @@ (fn [^java.sql.Connection conn] ;; We have to mock this to make it work with all DBs (with-redefs [sql-jdbc.describe-database/all-schemas (constantly #{"PUBLIC"})] - (->> (into [] (sql-jdbc.describe-database/fast-active-tables (or driver/*driver* :h2) (mt/db) conn nil nil)) + (->> (into [] (sql-jdbc.describe-database/fast-active-tables (or driver/*driver* :h2) conn nil nil)) (map :name) sort))))))) @@ -70,7 +70,7 @@ (mt/db) nil (fn [^java.sql.Connection conn] - (->> (into [] (sql-jdbc.describe-database/post-filtered-active-tables :h2 (mt/db) conn nil nil)) + (->> (into [] (sql-jdbc.describe-database/post-filtered-active-tables :h2 conn nil nil)) (map :name) sort)))))) -- GitLab