Представляю вашему вниманию небольшую js-библиотеку Jsqry.
Проще всего проиллюстрировать её назначение следующим примером.
До:
var name;
for (var i = 0; i < users.length; i++) {
if (users[i].id == 123) {
name = users[i].name;
break;
}
}
После:
var name = one(users, '[_.id==?].name', 123);
Библиотечка позволяет извлекать информацию из объектов/массивов в одну строку, используя несложный язык запросов, вместо написания циклов (подчас вложенных).
По сути, она реализует всего две функции:
- query — для возвращения списка результатов и
- one — для возвращения первого найденного результата.
Список возможностей включает:
- Фильтрацию
- Трансформацию
- Индексы/срезы в стиле Python
Библиотека появилась спонтанно в одном проекте, построенном на модном ныне подходе одностраничного приложения. Мы загружаем один большой JSON, части которого затем используются для рендеринга на клиенте разных представлений сайта. И вот для выдирания этих самых частей и захотелось более удобного способа. Затем, впрочем, библиотека оказалась востребована и в других случаях.
Поясню немного по функционалу. Запрос в общем случае может иметь вид
field1.field2[ condition or index or slice ].field3{ transformation }.field4
Тут:
- field1.field2.field3... — обычный доступ к полям объектов, как в js
- [ condition ] — фильтрация
- [ index ] — доступ по индексу, тоже как в js
- [ from:to:step ] — срезы в стиле Python
- { transformation } — преобразование объектов
На condition и transformation стоит остановиться подробнее.
На самом деле тут все очень просто. Достаточно понять, что каждое выражение внутри квадратных/фигурных скобок при выполнении заменяется на функцию по такому принципу:
condition_or_transformation ? function(_,i) { return condition_or_transformation }
(тут _ — значение передаваемого элемента, i — его индекс).
Пример:
query([1,2,3,4,5],'[_>2]{_+10}') // [13, 14, 15]
Также поддерживаются параметризация запроса:
query([1,2,3,4,5],'[_>?]{_+?}', 2, 10) // [13, 14, 15]
Комбинируя эти возможности можно строить весьма сложные и гибкие запросы. Больше примеров использования можно посмотреть тут.
Из интересного в реализации — AST дерево запроса кешируется, что придает библиотеке скорости.
Разумеется, моя библиотека не уникальна в своём роде. Стоит привести "небольшой" список аналогов:
- SpahQL — http://danski.github.io/spahql/
- Objectpath — http://objectpath.org/
- Sift.js — https://github.com/crcn/sift.js
- DefiantJS — http://defiantjs.com/
- и так далее...
Зачем еще одна библиотека? На самом деле, она поддерживает не весь возможный спектр типов запросов, а только то что было нужно в нашем проекте в большинстве случаев. За счет этого простота и скорость. Также, чрезвычайно простой API, вдохновленный подходом JQuery.
Буду рад выслушать критику и предложения по улучшению.
Комментарии (58)
nazarpc
20.06.2016 18:32+8За что я люблю LiveScript, так это за то, что подобные вещи можно достаточно компактно писать без библиотек.
До:
query([1,2,3,4,5],'[_>2]{_+10}')
После:
[1,2,3,4,5].filter(-> it>2).map(-> it+10)
it
это стандартная для LiveScript переменная, обозначающая первый аргумент функции. И никакого парсинга в рантайме не нужно.
Или же ES2016, если странспайлер используется:
[1,2,3,4,5].filter(it => it>2).map(it => it+10)
xonix
20.06.2016 18:46Спасибо, интересно.
А такой юз-кейс:
var hotel = { name: 'Name', facilities: [ {name:'Fac 1', services: [ {name:'Service 1', visible:false}, {name:'Service 2'} ]}, {name:'Fac 1', services: [ {name:'Service 3'}, {name:'Service 4', visible:false}, {name:'Service 5'} ]} ] }; console.info(query(hotel,'facilities.services[_.visible !== false].name')); // [ 'Service 2', 'Service 3', 'Service 5' ]
IaIojek
20.06.2016 19:24Два пути: красивый, но с добавлением одной функции в Array.prototype
Array.prototype.concatItems = function(){ return Array.prototype.concat.apply([], this) }; hotel.facilities.map(_=>_.services).concatItems().filter(_=>_.visible !== false).map(_=>_.name); // [ 'Service 2', 'Service 3', 'Service 5' ]
и не такой красивый
[].concat.apply([], hotel.facilities.map(_=>_.services)).filter(_=>_.visible !== false).map(_=>_.name); // [ 'Service 2', 'Service 3', 'Service 5' ]
jMas
20.06.2016 20:15+5hotel.facilities.map(_=>_.services).reduce((a,b)=>a.concat(b)).map(_=>_.name);
raveclassic
20.06.2016 21:47+1Вот да, только хотел спросить, чем стандартные map/reduce/filter не угодили.
IaIojek
21.06.2016 15:01+2Я подумал об этом, но только после отправки комментария.
Замечу, что у такого решения есть недостаток — вычислительная сложность у него O(n*m) где n — суммарное количество элементов в подмассивах, m — количество подмассивов. У concat это O(n).raveclassic
21.06.2016 15:23+1На самом деле тут вариантов несколько.
Если нужна лаконичнось, все укладывается в 1 reduce:
hotel.facilities.reduce( (acc, f) => acc.concat(f.services.filter(s => s.visible !== false).map(s => s.name)), [] ); // ["Service 2", "Service 3", "Service 5"]
Если нужна скорость — пожалуйста.
hotel.facilities.reduce((acc, f) => { f.services.forEach(s => { if (s.visible !== false) { acc.push(s.name); } }); return acc; }, []); // ["Service 2", "Service 3", "Service 5"]
Это я все к тому, что вариантов много и есть возможность использовать нужный, потому-что JS гибок. Но данная библиотека только повышает количество возможных способов, хотя краевые задачи все-равно придется решать с использованием нативных средств.xonix
21.06.2016 15:34Но данная библиотека только повышает количество возможных способов
Не только. Её вариант проще Вашего лаконичного варианта, правильно обрабатывает null/undefined, работает на любом древнем JS.
По-моему плюсов не так и мало.
query(hotel,'facilities.services[_.visible !== false].name')
Zibx
21.06.2016 16:08+1Что значит правильно обрабатывает? Нул не должен обрабатываться никак и undefined, на самом деле, тоже. Сейчас я почитаю код библиотеки и точно скажу насколько оно правильно.
Zibx
21.06.2016 16:15+1Посмотрел. Функция defined плохая. Обычно требуется не проверка «a.b === undefined», а «b in a», потому что undefined может быть действительно значением поля, которое мы хотим извлечь. Если заявлена поддержка старых браузеров, то там значение undefined вообще можно переопределить, по этой причине я долго оборачивал весь код в функцию (function(undefined){ код })(), пока не перешел на void 0.
Zenitchik
21.06.2016 16:48там значение undefined вообще можно переопределить
Тот кто это сделает — будет гнусным извращенцем. За то, как поведёт себя код в руках гнусного извращенца, разработчик не отвечает.raveclassic
21.06.2016 16:59К сожалению, тут проблема двусторонняя… И «гнусный извращенец» может пролезть к вам в сборку с 50-ой зависимостью какого-нибудь npm-пакета :)
Zenitchik
21.06.2016 17:24Среди разработчиков сколь-нибудь серьёзных библиотек извращенцев нет, а несерьёзные — проще самому написать.
xonix
21.06.2016 16:32Что значит правильно обрабатывает
Как Ваши варианты решения отработают на какой-то из записей facilities без поля services?raveclassic
21.06.2016 16:50+1Ну окей. Что, если мне нужно показать сообщение об ошибке, что с сервера пришли битые данные (без services — просто абстрактный пример)? Ваша библиотека, так сказать, прибила гвоздями проверку на undefined, и, соответственно, молча продолжит выборку, когда, используя стандартные функции, можно добавить и/или исключить все необходимые проверки.
Это гибко и удобно, хоть и не позволяет сэкономить лишние пару строк.Zibx
21.06.2016 17:04+1Даже не про этот кейс, тут можно итоговый length проверить. А вот приходит с сервера [{name:1}, {name: undefined}, {name:7}] -> обычно из него хотят отрисовать вьюху где первый и последний элемент будет содержать 1 и 7, а вместо второго надпись «введите значение», с волшебной библиотекой мы не узнаем о том что значения во втором поле нет, а особенно важно что мы не узнаем что его нет именно во втором элементе. Причём я понимаю когда эта библиотека может быть удобна, но такие граничные поведения надо писать огромными буквами в документации, потому что это может вылиться в часы отладки через месяц использования библиотеки.
xonix
21.06.2016 17:04Ну это философский спор. Но, например, разработчики angularjs сделали так же.
Forgiving: In JavaScript, trying to evaluate undefined properties generates ReferenceError or TypeError. In Angular, expression evaluation is forgiving to undefined and null
raveclassic
21.06.2016 17:14+1Кстати, это очень здорово, что Вы упомянули этот момент в ангуляре. В свое время он доставил немало «удовольствия» в поиске места, куда приткнуться, чтобы понять странность получаемого результата и отладить это их «небольшое усовершенствование». Думаю, что с jsqry рано или поздно случилось бы то же самое…
Zibx
21.06.2016 00:48Может автор учёл undefined элементы этой цепочки.
xonix
21.06.2016 01:32Весьма верное замечание! Вариант с Jsqry корректно прожует вариант объекта без поля services. Вариант с ES-кунг-фу скорее всего упадет с Null Pointer.
napa3um
21.06.2016 03:15Не упадёт, если обратиться к несуществующему проперти, оно будет undefined. При попытке обращения к более вложенным полям несуществующего поля упало бы, на этот случай можно использовать _.get из lodash или синтаксис obj?.prop из CoffeeScript.
raveclassic
21.06.2016 10:38+1Вся необходимая логика (да и вообщем-то любые проходы по коллекциям) так или иначе сводится к map/reduce. Собственно, вся логика выборки (в том числе и все-возможные проверки на undefined/null/коня_в_вакууме) прекрасно умещается в лямбдах для них.
«ES-кунг-фу» гораздо более предсказуемо, его проще и удобнее отлаживать и, что самое важное, поддерживать — это часть стандарта.Zibx
21.06.2016 16:05Я не спорю с кунг-фу, поскольку сам люблю строить развесистые комбо, но хотел обратить внимание что (возможно (не читал код этой библиотеки) ) приведённая цепочка мэп\фильтро\редьюсов не является эквивалентом.
nazarpc
20.06.2016 20:51+3Я бы реализовал следующим образом.
LiveScript:
[].concat(...hotel.facilities.map(-> it.services.filter(-> it.visible!==false).map(-> it.name)))
ES2016:
[].concat(...hotel.facilities.map(it => it.services.filter(it => it.visible!==false).map(it => it.name)))
Да, этот конкретно вариант уже более многословен, тем не менее никаких библиотек не нужно и сторонний наблюдатель с относительной легкостью сможет понять что происходит. Хотя в одну строчку в реальном проекте я бы не писал:)
TheShock
22.06.2016 07:34+1А зачем пример на LiveScript, если пример — на ES2016 то же самое?
nazarpc
22.06.2016 09:45+1Изначально на LS написал, вот и здесь повторил.
А вообще я считаю что LS незаслужено обделен вниманием. В нём много интересных решений, которых мне не хватало в CoffeeScript, возможно, мой комментарий вынудит кого-то зайти на сайт и посмотреть что он собой представляет.
Zibx
20.06.2016 18:47+3О, это тот редкий случай когда eval и new Function имеют право на жизнь. Либо генерировать функцию и делать кэш по строке, либо функцию которая компилирует выражение в функцию, и возвращает функцию которая принимает непосредственно объект.
jt3k
21.06.2016 01:33что Вы имеете в виду?
Zibx
21.06.2016 16:18+2Уже посмотрел что решение использует кэширование ast, значит со скоростью всё не так плохо как я предполагал. И наличие самого AST тоже было очень приятно. Я имел в виду отдавать не результат вычислений, а функцию с замкнутой функцией соответствующей конкретному AST, после чего эта функция принимает на вход данные, а даёт результат.
onto
20.06.2016 18:55+4В первом примере нужен break:
var name; for (var i = 0; i < users.length; i++) { if (users[i].id == 123) { name = users[i].name; break; } }
xGromMx
20.06.2016 19:12+6Нужно больше строковых конструкций! Как это дебажить?
xonix
20.06.2016 19:20-1В этом смысле так же как и регулярные выражения, JQuery-селекторы или тот же SQL.
xGromMx
20.06.2016 19:28+2Тогда лучше взять что-то типа lodash/fp, Ramda, воспользоваться линзами (хотя это больше для хаскеля)
xonix
20.06.2016 19:30А что — разве будет легче дебажить?
xGromMx
20.06.2016 19:32+1Конечно, я же могу раскидать лямбды на именованные функции если надо + `tap` у lodash
xonix
20.06.2016 19:47-2Ну когда запрос настолько сложен, что требует дебага, можно и в коде переписать.
А так-то можно и в Jsqry, хотя, признаться, о таком способе использования я раньше не думал.
function f1(elt) { return elt > 2 } function f2(elt) { return elt + 10 } console.info(jsqry.query([1,2,3,4,5],'[ ?(_) ]{ ?(_) }', f1, f2)); // [ 13, 14, 15 ]
jMas
20.06.2016 19:53+1Натыкался на JSONSelect, 1,5k звезд на гитхабе, но к сожалению, последний апдейт 3 года назад.
Из приятного (что лично нравится мне) — это CSS selector-like way для выборки данных (то есть довольно интуитивно для веб-девелоперов), есть тесты, и есть подтверждение востребованности в виде большого количества девелоперов обративших на это внимание.
Galamoon
20.06.2016 21:39ммм выглядит интересно, но реальная польза будет когда подобный синтаксис запросов будет описан на нативном языке в виде модуля ES6 или ES7.
SDSWanderer
20.06.2016 23:01Из подобного рекомендуемую https://github.com/davidgtonge/underscore-query — mongodb-like синтаксис как по мне гораздо лучше.
razetdinov
20.06.2016 23:02+1Чем это лучше JSPath?
xonix
20.06.2016 23:36Библиотеки очень похожи по задумке. Jsqry значительно проще как по устройству так и по использованию за счет переиспользования обычного js для предикатов, в JSPath же изобрели свой язык предикатов со своими операторами и т.д. По функционалу, имхо 80-90% совпадает. Хотя в Jsqry срезы более продвинутые (поддерживется step параметр, как в Python), а также не нашел трансформации у них, только фильтрация. С другой стороны, в JSPath, разумеется есть и возможности, коих нет в Jsqry, например, '..' и '|' в location path. А, да, у них еще более громоздкий синтаксис подстановок — именованные вместо '?'. Впрочем, это можно расценить и как плюс.
timramone
20.06.2016 23:30Да, нужно больше языков, конечно!
Если серьезно, то по-моему после появления arrow-functions в ES6 или typescript (в котором они были от рождения), это всё на столько бесполезно…
kemsky
21.06.2016 00:45+1Самый серьезный минус в том, что рефакторинг и подсветка в ИДЕ не будет работать и не будет работать линт и тайпскрипт компилятор так же ничего проверить не сможет.
Нечто похожее я сделал для ActionScript, но сделал это на функциях и как часть библиотеки для работы с коллекциями. В принципе, я согласен с тем, что arrow-functions эту нишу во многом закрывают.
pterolex
21.06.2016 01:40+2Array.prototype.find и filter, имхо, вполне решают основную часть проблем (и сразу есть в стандарте ES5-ES2015). В крайнем случае, можно и lodash подключить
helgihabr
21.06.2016 11:51+1Спасибо за проделанную работу.
Лично я планирую использовать эту библиотеку.
А то, что эту задачу можно решить в ES6 или typescript, ну так отлично. Ее можно решить используя еще множество других инструментов.
В данном случае мне нравится, что это JS изначально, а не «обертки», да и размер самой библиотеки для меня является тоже плюсом.
babylon
21.06.2016 14:31Владимир, ну чего мелочиться. До делайте до конца, то есть до E4J :) Подглядывать можно сюда https://github.com/s9tpepper/JSONTools
shasoft
Тесты скорости не проводились? Т.е. насколько теряется скорость при использовании библиотеки.
xonix
Скурпулезных замеров не проводил, но вот есть небольшой бенчмарк https://github.com/xonixx/jsqry/blob/master/bench.js.
У меня 100000 прогонов запроса вида
отрабатывает за 425ms.