-
Мобильное приложение
Ох, что я в интернете прочел.
Значит, проблема: люди тупят в телефон на ходу. Спотыкаются, врезаются в столбы, падают в фонтаны.
Знаете, что предлагает автор? Готовы? Написать мобильное приложение, которое будет читать камеру, распознавать препятствия и сигналить пользователю.
Похоже, автор (дизайнер) не читал параграф Лебедева об идее на минус миллион. Это когда дизайнер думает, что идея офигенна только потому, что он ее придумал, а проблемы бизнеса не его дело.
Давайте представим такое приложение. Батарея будет садиться в один момент. Чтение камеры это дорогая операция, а распознавание изображений еще дороже. При этом нужно, чтобы не подвисало основное приложение, с которым пользователь сейчас работает.
Это технические трудности, предположим, мы их решили.
При ходьбе камера смотрит почти отвесно вниз, поля обзора хватит на два метра вперед. Это расстояние человек пройдет за две секунды. Даже если приложение справится за 0.1 секунды, бедняга не успеет остановиться.
Пользователь же не просто смотрит в экран, он мысленно погружается в контекст. Вот идет Вася, читает Вконтактик. Маша пишет: приходи ко мне ночевать. Все, у Васи мысли о сексе, кровь отлила от мозга в пах. А через два метра открытый люк.
Поможет ли Васе приложение? Сомневаюсь.
Как различать опасности? Это может быть и бордюр, и сливная решетка, и кусок брусчатки, приподнятый на сантиметр и потому невидимый приложению.
В толпе люди разделены метром. Человек впереди это препятствие?
Даже если представить, что приложение использует новейшие технологии (нейросети, глубокое обучение), это делает его хуже. Оно даст ложное чувство защиты. Человек будет выходить на середину дороги и попадать под автобус.
В странах с развитой судебной системой производителя завалят исками. Люди будут так же падать, разбивать носы, но вдобавок отсуживать суммы за ущерб здоровью.
В защиту этой ахинеи можно сказать, что для слепых делают что-то отдаленно похожее. Такие перчатки-локаторы чтобы надевать на запястье. Внутри Ардуино, эхо-локатор и пуговица-вибратор. Ультразвук отражается от препятствия. Если дистанция меньше метра, вибратор дрожит.
Даже с этим девайсом масса проблем, при том что слепой каждую секунду слушает сигнал и все мысли его о том, как бы не упасть. И дальность локатора 15 метров против двух у камеры.
Вывод: если мобильный телефон приносит проблемы, нужно убрать телефон. Делать мобильное приложение – что-то за гранью мыслимого. Хуже, чем лечить рак легких щадящими сигаретами.
-
Центрирование курсора
В редакторах или IDE крайне редко встречается следующая функция.
Предположим, я дописываю код в конец файла. Если кода больше, чем один экран, текст можно набирать только внизу. Промотать редактор на середину нельзя – файл закончился, скролл вниз не работает.
Это страшно раздражает. Я привык, что глаза смотрят в центр экрана. Гораздо чаще мы редактируем старый код нежели пишем новый, поэтому курсор в центре. Долго смотреть вниз неудобно, устают глаза и шея.
Слева на картинке Саблайм. Файл закончился, набирать текст приходится внизу, смотаться нельзя. Емакс проматывается в середину сам. Сочетание Ctrl+L центрирует буфер по требованию, повторное нажатие перематывает вверх, оставляя одну строчку. Обратите внимание, что буфер кончился, слева нет нумерации строк, но для перемотки это не проблема.
В VS Code смотаться можно, но при редактировании внизу он проматывает на одну строчку. Приходится мотать вручную, выполняя работу машины. Написал-промотал. Емакс же сразу прыгает в центр экрана.
IDEA по умолчанию оставляет задел в пять пустых строчек в конце файла, но ниже нельзя. Нормальная промотка включается где-то в недрах настроек. Опять же, нет авто-центрирования.
Раньше я боролся с напастью так: зажимал Энтер и вставлял 50 переносов. Так я мог проматывать файл ниже его логического конца, а с точки зрения редактора от все еще продолжался. Время от времени эта дичь попадала в коммиты. Был и другой вариант – сплющить окно редактора, чтобы оно доходило только до середины экрана.
Почему об этом думали в 1970-бородатом году, а сейчас никого не волнует?
-
Вконтакт
Не могу передать, как меня бесит ВК. В нем все плохо. Дурацкое название с предлогом. Убогий дизайн, когда нажимаешь на выпадашку, а там одна опция. Но главная беда как раз в том, ради чего это это создавалось – в общении.
Предположим, хочу повесить объявление в группе жильцов ЖК. В шапке группы жирная кнопка “написать сообщение”. Открывается диалог:
Минуточку, мне нужно, чтобы отвечали все, а не только администраторы. Или остальные тоже могут? Как понять?
Что за ссылка “перейти к диалогу с сообществом”? А сейчас что, не диалог? На всякий случай нажал, там пустой экран. Ясно, этим не пользуются. Зачем ссылка?
В группе есть “обсуждения”: жильцы второго дома, любители котят и тд. Этакий форум из нулевых. Объявление в одном обсуждении не увидят в другом.
Сбоку ссылка на чат. Что-то вроде Телеграма, только внутри ВК. Слева личные сообщения.
Черт, как разместить текст, чтобы его прочли все жильцы?
Все засунуто под выпадашки, даже когда места полно. Всюду “еще”, “подробней” и прочая импотенция.
Что за дизайнеры верстали все это? Кто это проектировал?
Я слышу: сперва добейся, потом предъявляй. Но совершенно верно сказал Лебедев: если известная субстанция приносит много денег, быть ею от этого она не перестает.
-
Дети
Чтобы ребенку было интересно, нужно, чтобы родителю было интересно. Чтобы ребенку нравилось, нужно, чтобы родителю тоже нравилось.
Удивительно, как много взрослых не понимают этой банальности. Они считают, что ребенку можно на весь день включить мультики и забыть о его существовании. Что можно подарить книгу, и типа читай сам. Что можно подарить китайское говно взамен качественной вещи.
Дети все прекрасно понимают. В силу физиологии они не могут развернуто сформировать претензию или обиду. Но халтуру чуют за версту. Это особенно заметно в период с 2 до 3 лет, когда речь еще толком не поставлена, но уже есть устойчивые вкусы и понимание всего, что происходит вокруг.
Чтобы ребенок с упоением читал, нужно читать ему каждый день несколько лет. Чтобы с интересом смотрел мультик и понимал сюжет, нужно смотреть вместе с ним.
Качественная детская продукция мало чем отличается от аналогов для взрослых. Если это игрушка, она должна иметь приятные пропорции, быть устойчивой к неаккуратному обращению, не звучать слишком громко. Если техника, то быть правильно спроектированной, с понятным дизайном, качественной сборкой.
Если это мультфильм, то повествование должно быть последовательным, проблемы персонажей понятны, хорошо прослеживается главная мысль: обманывать нехорошо или если накосячил, то исправь.
Никогда не делайте скидку на то, что это ребенок. Он такой же человек, как и вы. Критерии качества у детей такие же высокие.
Остается печальный случай, когда ребенок потребляет халтуру (вещи, информацию) из-за отсутствия вкуса у родителей. Увы, эта проблема неразрешима и будет передана на поколение вперед.
Может, и ваш ребенок одупляет телевизор третий час, пока вы это читаете?
-
Ворчание ягнят
Никита и Рахим запустили сайт, где можно посетовать на дебильный интерфейс. Встречайте, Ворчание ягнят. Написан на Кложе.
Дизайн не моя стихия, и писать в блог про каждую кривую кнопку мне кажется лишним. Это скорее для Твиттера, но я с ним не дружен. Так что все наболевшее шлю в приват Никите, а он обещал запостить.
Подписывайтесь по РСС, там будет изредка мелькать и от моего имени.
UPD стрим в процессе создания:
-
Фаерфокс
Мне кажется, в недалеком будущем Фаерфокс умрет. Не полностью и не буквально, конечно. Просто сократит долю рынка настолько, что станет неликвидным. Ему присвоят звание “жемчужины опен-сорса”, гики будут стенать, причитать и сидеть на неподдерживаемой версии трехлетней давности, как это случилось с Оперой после перехода на Веб-кит. На поддержке останутся FF-only легаси-системы.
Кто-то сказал, что браузеры – продукт массового потребления. В целом, они все работают хорошо и покрывают нужды рядового пользователя. Уже нет проблем с отрисовкой элементов. Основные усилия уходят на то, чтобы еще теснее интегрировать браузер с операционной системой и сервисами компаний.
В этом плане все в порядке у Хрома, Сафари и Эджа (который Эксплорер). Гугл вливает в браузер колоссальные деньги, поскольку Хром – это окно во все сервисы: рекламу, почту, карты. Сафари связан с Маком и системой Эпла. Эдж, понятно, по самые уши интегрирован с Виндой, Бингом, Икс-боксом, офисным пакетом.
Одного браузера недостаточно, нужны сервисы. Свои некоторые программы Гугл подкрепляет расширениями в Хроме. Так, при открытии Гугло-дока Хром молча ставит расширения Google Docs, Google Sheets и другие. Так они работают быстрее. А с чем интегрироваться Фаерфоксу?
На рынке могут появиться разработки для снобов вроде Вивальди. Они обязательно снискают своего пользователя, но никогда не выйдут на массовый рынок. Потенциально новый браузер могут предложить только гиганты вроде Амазона или Фейсбука.
Лично мои претензии к Фаерфоксу в том, что во-первых, он работает медленней Хрома. Пусть меньше загружены процессор и память, мое время дороже. Второе, Фаерфокс весь из себя для гиков. Это слегка умиляет, но в долгосрочной перспективе раздражает, потому что браузер нужен не только для работы, но и развлечения.
-
Реальность
В плане профессии я знаю очень мало.
На Си и Ассемблере писал только в институте, уже ничего не помню.
Плаваю в указателях, памяти, куче. Код на C++ не понимаю.
О нейросетях и машин-лернинге имею самые смутные представления.
Про Биткоин читал только пару бульварных статей.
Знаю три команды из Гита. Путаю, что куда мерджить.
Накупил книжек, поставил пылиться на полку.
Не напишу и пяти строчек на баше без интернетов.
Не дочитал SICP. Хотел сделать все практические задания, слился.
Кнут только в самых влажных мечтах.
Компилятор или язык в жизни не осилю.
Пилил свой проект, бросил.
Пишу на Кложе, но в Джаве ни бум-бум. На Кложе потому, что язык редкий.
Всю жизнь клепаю формочки к базе.
Ненавижу JS, потому что не понимаю его, или наоборот.
Емакс и Лисп чтобы быть не таким как все.
Говорю по-английски с проблемами. Ссу провести вебинар на английском.
Гуглю каждый HTML-тег. Копипащу со Стека.
Умничаю в чате. Высмеиваю мессаджеры, сам же ими пользуюсь.
Не знаю нужных хоткеев, не ставлю клевых программ.
Не трекаю время, непродуктивен. Прокрастинирую.
Разбираюсь только в Лиспе, Мейк-файлах и немного в Постгресе. Тем и живу.
-
Conditional Queries in Datomic
Let’s discuss one thing related to Clojure and Datomic that might be a bit tricky especially if you have never faced it so far.
Imagine you return a list of some entities to the client. It might be a list of orders, visits or whatever. Usually, when a user is exploring a long list of something, it’s a good idea to let them filter the list by some criteria: price, date, age etc. On the server side, you need to apply those filters conditionally. Say, when the
date_from
query string parameter is passed, you apply it to the query as well or live it untouched otherwise. The same for the rest of filters.Things become more tough when filters are applied to foreign entities. For example, an order references a user, and a user references a department. If the
department_name
parameter has been passed, you should join all the required tables and filter a proper field.Joining all the tables even if no filters were supplied is a wrong approach. Join operations are expensive and should never be performed in vain. A toolset that joins tables should take into account which ones has already been added into a query and never link them twice.
Such systems that control the cases mentioned above are names ORMs. They are pretty complicated and full of ugly code and implicit hacks. But on the top level they behave quite friendly. Say, in Django (a major Python framework) I would perform something like that:
query = models.Order.objects.all() department_name = request.query_string.get("department_name") if department_name: query = query.filter(user__department_name=department_name)
Looks pretty neat. This automatically joins
users
anddepartment
tables under the hood usinginner join
SQL clause and putsdepartment.name = ?
condition intowhere
section.People who work with such non-wide spreaded languages as Clojure usually do not use ORMs. But still, we need to build complicated queries. The community offers a handful of libraries (YeSQL, HoneySQL, Korma) where a query is being constructed within data structures: vectors, maps. I’ve been always against that approach. Before getting more experienced with Clojure, I felt uncomfortable constructing nested vectors like this one:
[:select [:foo :bar] :from :test :where [(= :name "test") (when age-param (> :age age-param))]]
The reason why I do not appreciate that is I cannot see the final query behind brackets and colons. It will definitely fail me once I need to express something like this (a fragment of a query from production):
select foo.id, array_agg(foo.type_id) filter (where foo is not null) as type_ids, foo.name from foo_table as foo
Modern libraries say it’s easy and fun to express your queries through data structures, but it is not, really. It becomes a challenge when applying multiple conditions to a data structure without seeing the final result.
A good approach might be using a templating system. Say, HugSQL library allows to inject Clojure snippets into your SQL query. Those snippets are surrounded with standard SQL comments so they do not break syntax. There won’t be an error if you copy and paste such a Clojure-instrumented query into some RDBS administration tool.
Here is an example of declaring such a query:
-- :name get-oreders :? select o.* from oreders o /*~ (when (:department-name params) ~*/ join user u on o.user_id = u.id join departments d on user.department_id = d.id where d.name = :department-name /*~ ) ~*/ order by o.created_at;
Than it compiles into a Clojure function:
;; no joins, no filters (get-oreders db) ;; causes joins and filtering (let [dep-name (-> request :params :department-name)] (get-oreders db {:department-name dep-name}))
You may go further and include Selmer, a template library inspired by Django. It’s aimed at HTML rendering first but still may be used for any kind of documents including SQL.
As I see it, a good templating system would be enough to generate SQL that fits your business logic.
Now I’d like to discuss the same problem when using Datomic instead of classical RDBS solutions. All the tutorials that I have read do not cover a case then you need to apply several filters to a query. Suddenly, it may really become a problem. Let’s return to our example with orders. Once you don’t have any filters, the query looks simple:
'[:find (pull ?o [*]) :in $ :where [?o :order/number]]
But if you’ve got a department name you need to:
- inject a new parameter into
:in
section; - inject additional clauses into
:where
section; - prevent joining a user or a department entities twice if you need to filter by other department or user field.
As we’ve seen, once you have a template system, you may render SQL queries as well. All you need is to write a query, test how does it behaves and then wrap some parts of it with special conditional tags.
In Datomic, a query is usually a vector of symbols. Moreover, an immutable one. Thus, you cannot modify a query and adding something in the middle of it would be difficult. Surely you could wrap a query into an atom or track indexes where to inject a new item somehow but all of that would be a mess.
What I propose is using a special kind of a query represented as a map with
:find
,:where
and other keys. As the Datomic documentation says, when processing a query, every vector is turned into a map anyway. If we had a map, it would be easier to inject new items into it.To avoid wrapping a map with an atom or redefining it continuously inside
let
clause, there is a great form named cond->. It is a mix of both threading macro andcond
clause. It takes an initial value and a bunch of predicate/update pairs. If a predicate form evaluates in true, an update form is fired using the standard threading macro. Thus, an update form should be either a function or a list where the second argument is missing and will be substituted with a value from a previous pair.What’s the most interesting about
cond->
is unlikecond
orcase
forms, its branches are evaluated continuously. Each update form takes a value that a previous form has produced. In other terms, an initial value goes through multiple updates without being saved in some temporary variable.In example below, I’ve got a data set that consists from
user
,location
andvisit
entities. Bothuser
andlocation
are simple ones and store just dates, strings and so on. Avisit
is a bit more complex. It means that a user has visited a location and assigned a mark to it. Therefore, a visit references a user and a location entities as well.The goal is to get an average mark for a specific location. In addition, such a value might be filtered by user’s age, gender or location’s country name. Those parameters come from a query string and could be either totally skipped, passed partially or completely. I’ve got a function that accepts a location id and a map of optional parameters:
(defn location-avg [location-id {:keys [fromDate toDate fromAge toAge gender]}]
The initial Datomic query:
(def query-initial '{:find [(avg ?mark) .] :with [?v] :in [$ ?location] :args [] :where [[?v :location ?location] [?v :mark ?mark]]})
Now, here is a long pipeline with comments:
(cond-> query-initial ;; First, add two initial arguments: database instance and location reference. ;; This form will always be evaluated. true (update :args conj (get-db) ;; returns the DB instance [:location/id location-id]) ;; location reference ;; If either from- or to- date were passed, join the `visit` entity ;; and bind its `visited_at` attribute to the `?visited-at` variable. (or fromDate toDate) (update :where conj '[?v :visited_at ?visited-at]) ;; If the `fromDate` filter was passed, do the following: ;; 1. add a parameter placeholder into the query; ;; 2. add an actual value to the arguments; ;; 3. add a proper condition against `?visited-at` variable ;; (remember, it was bound above). fromDate (-> (update :in conj '?fromDate) (update :args conj fromDate) (update :where conj '[(> ?visited-at ?fromDate)])) ;; Do the same steps for the `toDate` filter, ;; but the condition slightly differs (< instead of >). toDate (-> (update :in conj '?toDate) (update :args conj toDate) (update :where conj '[(< ?visited-at ?toDate)])) ;; To filter by user's fields, we bind a user reference ;; to the `?user` variable: (or fromAge toAge gender) (update :where conj '[?v :user ?user]) ;; If from/to age filters we passed, bind user's age ;; to the `?birth-date` variable. (or fromAge toAge) (update :where conj '[?user :birth_date ?birth-date]) ;; Then add placeholders, arguments and where clauses ;; for specific filters: fromAge, if passed... fromAge (-> (update :in conj '?fromAge) (update :args conj (age-to-ts fromAge)) (update :where conj '[(< ?birth-date ?fromAge)])) ;; ...and the same for toAge. toAge (-> (update :in conj '?toAge) (update :args conj (age-to-ts toAge)) (update :where conj '[(> ?birth-date ?toAge)])) ;; To filter by gender, bind user's gender to a variable ;; and add a clause: gender (-> (update :in conj '?gender) (update :args conj gender) (update :where conj '[?user :gender ?gender])) ;; The final step is to remap a query (see below). true remap-query)
Remapping a query is important because the initial data is a bit wrong. The proper structure for a map query looks as follows:
{:query <query-map> :args [db location_id fromDate ...]}
In my case, I believe it’s simpler to keep one-level map rather than deal with two levels (
:query
first, then:args
). It would force me to useupdate-in
instead if justupdate
and write more code. Here is theremap-query
function:(defn remap-query [{args :args :as m}] {:query (dissoc m :args) :args args})
Finally, let’s check our results. If somebody passes all the filters, the query will look like:
{:query {:find [(avg ?mark) .], :with [?v], :in [$ ?location ?fromDate ?toDate ?fromAge ?toAge ?gender], :where [[?v :location ?location] [?v :mark ?mark] [?v :visited_at ?visited-at] [(> ?visited-at ?fromDate)] [(< ?visited-at ?toDate)] [?v :user ?user] [?user :birth_date ?birth-date] [(< ?birth-date ?fromAge)] [(> ?birth-date ?toAge)] [?user :gender ?gender]]}, :args [<db-object> [:location/id 42] 1504210734 1504280734 20 30 "m"]}
Now you pass it into datomic.api/query function that accepts a map-like query. In my case, the result is something like:
4.18525
.As you have seen, composing complicated queries with Datomic might a bit tricky due do immutability and differences between string templates and data structures. But still, Clojure provides rich set of tools for processing collections. In my case, the standard
cond->
has made all the pipeline. Neither atoms nor other tricks to track the state were required. There is a common rule: once you’ve got stuck with a data structure, keep yourself from inventing “smart” ways to deal with it. There is probably a built-in macro inclojure.core
for that. - inject a new parameter into
-
Фейсбук прекрасен
Каждый месяц повторяется печальное событие: мне нужно зайти в ФБ и сделать анонс митапа. Я тяну время, но часа X не избежать.
Даже не знаю, что может быть хуже Фейсбука: столь тормозной и неорганизованный сайт еще поискать. При том что денег у них до жопы.
Погрязли в жадности и бюрократизме.
-
In-Memory SQLite Database In Clojure
Recently, I’ve been working with a SQLite database using Clojure. In this post, I’d like to share my experience that I’ve got from that challenge.
SQLite is a great tool used almost everywhere. Browsers and mobile devices use it a lot. A SQLite database is represented by a single file that makes it quite easy to share, backup and distribute. It supports most of the production-level databases’ features like triggers, recursive queries and so on.
In addition, SQLite has a killer feature to be run completely in memory. So, instead of keeping an atom of nested maps, why not to store some temporary data into well organized tables?
JDBC has SQLite support as well, you only need to install a driver, the documentation says. But then, I’ve got a problem dealing with in-memory database:
(def spec {:classname "org.sqlite.JDBC" :subprotocol "sqlite" :subname ":memory:"}) (jdbc/execute! spec "create table users (id integer)") (jdbc/query spec "select * from users") > SQLiteException [SQLITE_ERROR] SQL error or missing database > (no such table: users) org.sqlite.core.DB.newSQLException (DB.java:909)
What the… I’ve just created a table, why cannot you find it? An interesting note, if I set a proper file name for the
:subname
field, everything works fine. But I needed a in-memory database, not a file.After some hours of googling and reading the code I’ve found the solution.
The thing is, JDBC does not track DB connections by default. Every time you call for
(jdbc/*)
function, you create a new connection, perform an operation and close it. For such persistent data storages like Postgres or MySQL that’ fine although not effective (in our project, we use HikariCP to have a pool of open connections).But for in-memory SQLite database, closing a connection to it leads to wiping the data completely out form the RAM. So you need to track the connection in more precision way. You will create a connection by yourself and close it when the work is done.
First, let’s setup your project:
:dependencies [... [org.xerial/sqlite-jdbc "3.20.0"] [org.clojure/java.jdbc "0.7.0"] [com.layerware/hugsql "0.4.7"] [mount "0.1.11"] ...]
and the database module:
(ns project.db (:require [mount.core :as mount] [hugsql.core :as hugsql] [clojure.java.jdbc :as jdbc]))
Declare a database URI as follows:
(def db-uri "jdbc:sqlite::memory:"
Our database shares two states: when it’s been set up and ready to work and when it has not. To keep the state, let’s use
mount
library:(declare db) (defn on-start [] (let [spec {:connection-uri db-uri} conn (jdbc/get-connection spec)] (assoc spec :connection conn))) (defn on-stop [] (-> db :connection .close) nil) (mount/defstate ^{:on-reload :noop} db :start (on-start) :stop (on-stop))
Once you call
(mount/start #'db)
, it becomes a map with the following fields:{:connection-uri "jdbc:sqlite::memory:" :connection <SomeJavaConnectionObject at 0x0...>}
When any JDBC function or a method accepts that map, it checks for the
:connection
field. If it’s filled, JDBC uses that connection as well. If it’s not, a new connection is issued. In my case, every execute/query call created a new in-memory database and stopped it right after the call ends. That’s why the second query could not to findusers
table: because it was performed within another database.Now with the
db
started, you are welcome to perform all the standardjdbc
operations:(jdbc/execute! db "create table users (id integer, name text)") (jdbc/insert! db :users {:id 1 :name "Ivan"}) (jdbc/get-by-id db :users 1) ;; {:id 1 :name "Ivan"} (jdbc/find-by-keys db :users {:name "Ivan"}) ;; ({:id 1 :name "Ivan"})
Finally, you stop the db calling
(mount/stop #'db)
. The connection stops, the data disappears completely.For more complicated queries with joins, HugSQL library would be a good choice. Create a file
queries.sql
in yourresources
folder. Say, you want to write a complex query that filters a result by some values that probably are not set. Here is an example of what you should put intoqueries.sql
file:-- :name get-user-visits :? select v.mark, v.visited_at, l.place from visits v join locations l on v.location = l.id where v.user = :user_id /*~ (when (:fromDate params) */ and v.visited_at > :fromDate /*~ ) ~*/ /*~ (when (:toDate params) */ and v.visited_at < :toDate /*~ ) ~*/ /*~ (when (:toDistance params) */ and l.distance < :toDistance /*~ ) ~*/ /*~ (when (:country params) */ and l.country = :country /*~ ) ~*/ order by v.visited_at;
In your database module, put the following on the top level:
(hugsql/def-db-fns "queries.sql")
Now, every SQL template has become a plain Clojure function that takes a database and a map of additional parameters. To get all the visits in our application, we do:
(get-user-visits db {:user_id 1 :fromDate 123456789 :country "SomePlace"}) > ...gives a seq of maps...
Hope this article will help those who’ve got stuck with SQLite.