diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..d6079226 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,177 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DSQL.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DSQL.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/DSQL" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DSQL" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..2e7032e3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,19 @@ +How to build this documentation + +``` +sudo apt-get install python-sphinx python-setuptools +sudo easy_install pip + +sudo pip install sphinx_rtd_theme +sudo pip install sphinxcontrib-phpdomain + +make html +``` + +next open `html/index.html` in your browser + +``` +open html/index.html +``` + + diff --git a/docs/cache/catche.rst b/docs/cache/catche.rst new file mode 100644 index 00000000..e69de29b diff --git a/docs/cache/index.rst b/docs/cache/index.rst new file mode 100644 index 00000000..e69de29b diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..43cc0198 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,284 @@ +# -*- coding: utf-8 -*- +# +# DSQL documentation build configuration file, created by +# sphinx-quickstart on Thu Feb 4 16:28:49 2016. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys +import os +import sphinx_rtd_theme + +from sphinx.highlighting import lexers +from pygments.lexers.web import PhpLexer +lexers['php'] = PhpLexer(startinline=True, linenos=1) +lexers['php-annotations'] = PhpLexer(startinline=True, linenos=1) +primary_domain = 'php' + + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +#sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.todo', + 'sphinx.ext.coverage', + 'sphinxcontrib.phpdomain' +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'DSQL' +copyright = u'2016, Agile Toolkit' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = '1.1' +# The full version, including alpha/beta/rc tags. +#release = '1.0.0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +#language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +highlight_language = 'php' + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'sphinx_rtd_theme' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'DSQLdoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ('index', 'DSQL.tex', u'DSQL Documentation', + u'Agile Toolkit', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'dsql', u'DSQL Documentation', + [u'Agile Toolkit'], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'DSQL', u'DSQL Documentation', + u'Agile Toolkit', 'DSQL', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False + + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'http://docs.python.org/': None} + +from sphinx.highlighting import lexers +from pygments.lexers.web import PhpLexer +lexers['php'] = PhpLexer(startinline=True) +lexers['php-annotations'] = PhpLexer(startinline=True) +primary_domain = "php" # It seems to help sphinx in some kind (don't know why) diff --git a/docs/db/advanced.rst b/docs/db/advanced.rst new file mode 100644 index 00000000..f4ebdedf --- /dev/null +++ b/docs/db/advanced.rst @@ -0,0 +1,326 @@ +=============== +Advanced Topics +=============== + +DSQL has huge capabilities in terms of extending. This chapter explains just +some of the ways how you can extend this already incredibly powerful library. + +Advanced Connections +==================== +:php:class:`Connection` is incredibly lightweight and powerful in DSQL. +The class tries to get out of your way as much as possible. + +Using DSQL without Connection +----------------------------- +You can use :php:class:`Query` and :php:class:`Expression` without connection +at all. Simply create expression:: + + $expr = new Expression('show tables like []', ['foo%']); + +or query:: + + $query = (new Query())->table('user')->where('id', 1); + +When it's time to execute you can specify your PDO manually:: + + $stmt = $expr->execute($pdo); + foreach($stmt as $row){ + echo json_encode($row)."\n"; + } + +With queries you might need to select mode first:: + + $stmt = $query->selectMode('delete')->execute($pdo); + +The :php:meth:`Expresssion::execute` is a convenient way to prepare query, +bind all parameters and get PDOStatement, but if you wish to do it manually, +see `Manual Query Execution`_. + + +Using in Existing Framework +--------------------------- +If you use DSQL inside another framework, it's possible that there is already +a PDO object which you can use. In Laravel you can optimize some of your queries +by switching to DSQL:: + + $pdo = DB::connection()->getPdo(); + $c = new Connection(['connection'=>$pdo]); + + $user_ids = $c->dsql()->table('expired_users')->field('user_id'); + $c->dsql()->table('user')->where('id', 'in', $user_ids)->set('active', 0)->update(); + + // Native Laravel Database Query Builder + // $user_ids = DB::table('expired_users')->lists('user_id'); + // DB::table('user')->whereIn('id', $user_ids)->update(['active', 0]); + +The native query builder in the example above populates $user_id with array from +`expired_users` table, then creates second query, which is an update. With +DSQL we have accomplished same thing with a single query and without fetching +results too. + +.. code-block:: sql + + UPDATE + user + SET + active = 0 + WHERE + id in (SELECT user_id from expired_users) + +If you are creating :php:class:`Connection` through constructor, you may have +to explicitly specify property :php:attr:`Connection::query_class`:: + + $c = new Connection(['connection'=>$pdo, 'query_class'=>'atk4\dsql\Query_SQLite']); + +This is also useful, if you have created your own Query class in a different +namespace and wish to use it. + +Using Dumper and Counter +------------------------ + +DSQL comes with two nice features - "dumper" and "counter". Dumper will output +all the executed queries and how much time each query took and Counter will +record how many queries were executed and how many rows you have fetched through +DSQL. + +In order to enable those extensions you can simply change your DSN from:: + + "mysql:host=localhost;port=3307;dbname=testdb" + +to:: + + "dumper:mysql:host=localhost;port=3307;dbname=testdb" + "counter:mysql:host=localhost;port=3307;dbname=testdb" + "dumper:counter:mysql:host=localhost;port=3307;dbname=testdb" + +When this DSN is passed into :php:meth:`Connection::connect`, it will return +a proxy connection object that will collect the necessary statistics and +"echo" them out. + +If you would like to do something else with these statistics, you can set +a callback. For Dumper:: + + $c->callback = function($expression, $time, $fail = false) { + ... + } + +and for Counter:: + + $c->callback = function($queries, $selects, $rows, $expressions, $fail = false) { + ... + } + +If you have used "dumper:counter:", then use this:: + + $c->callback = function($expression, $time, $fail = false) { + ... + } + + $c->connection()->callback = function($queries, $selects, $rows, $expressions, $fail = false) { + ... + } + +.. _proxy: + +Proxy Connection +---------------- +Connection class is designed to create instances of :php:class:`Expression`, +:php:class:`Query` as well as executing queries. +A standard :php:class:`Connection` class with the use of PDO will do nothing +inside its execute() because :php:meth:`Expression::execute` would handle all +the work. + +However if :php:attr:`Connection::connection` is NOT PDO object, then +:php:class:`Expression` will not know how to execute query and will simply +call:: + + return $connection->execute($this); + +:php:class:`Connection_Proxy` class would re-execute the query with a different +connection class. In other words :php:class:`Connection_Proxy` allows you +to "wrap" your actual connection class. As a benefit you get to extend +:php:class:`Proxy` class implementing some unified features that would work with +any other connection class. Often this will require you to know externals, but +let's build a proxy class that will add "DELAYED" options for all INSERT +operations:: + + class Connection_DelayInserts extends \atk4\dsql\Connection_Proxy + { + function execute(\atk4\dsql\Expression $expr) + { + if ($expr instanceof \atk4\dsql\Query) { + + if ($expr->mode == 'insert') { + $expr->insertOption('delayed'); + } + + } + return parent::execute($expr); + } + } + +Next we need to use this proxy class instead of the normal one. Frankly, that's +quite simple to do:: + + $c = \atk4\dsql\Connection::connect($dsn, $user, $pass); + + $c = new Connection_DelayInserts(['connection'=>$c]); + + // use the new $c + +:php:class:`Connection_Proxy` can be used for many different things. + +.. _extending_query: + +Extending Query Class +===================== + +You can add support for new database vendors by creating your own +:php:class:`Query` class. +Let's say you want to add support for new SQL vendor:: + + class Query_MyVendor extends atk4\dsql\Query + { + // truncate is done differently by this vendor + protected $template_truncate = 'delete [from] [table]'; + + // also join is not supported + public function join( + $foreign_table, + $master_field = null, + $join_kind = null, + $_foreign_alias = null + ) { + throw new atk4\dsql\Exception("Join is not supported by the database"); + } + } + +Now that our custom query class is complete, we would like to use it by default +on the connection:: + + $c = \atk4\dsql\Connection::connect($dsn, $user, $pass, ['query_class'=>'Query_MyVendor']); + +.. _new_vendor: + +Adding new vendor support through extension +------------------------------------------ +If you think that more people can benefit from your custom query class, you can +create a separate add-on with it's own namespace. Let's say you have created +`myname/dsql-myvendor`. + +1. Create your own Query_* class inside your library. If necessary create your + own Connection_* class too. +2. Make use of composer and add dependency to DSQL. +3. Add a nice README file explaining all the quirks or extensions. Provide + install instructions. +4. Fork DSQL library. +5. Modify :php:meth:`Connection::connect` to recognize your database identifier + and refer to your namespace. +6. Modify docs/extensions.rst to list name of your database and link to your + repository / composer requirement. +7. Copy phpunit-mysql.xml into phpunit-myvendor.xml and make sure that + dsql/tests/db/* works with your database. + +Finally: + - Submit pull request for only the Connection class and docs/extensions.rst. + + +If you would like that your vendor support be bundled with DSQL, you should +contact copyright@agiletoolkit.org after your external class has been around +and received some traction. + +Adding New Query Modes +---------------------- + +By Default DSQL comes with the following :ref:`query-modes`: + + - select + - delete + - insert + - replace + - update + - truncate + +You can add new mode if you wish. Let's look at how to add a MySQL specific +query "LOAD DATA INFILE": + +1. Define new property inside your :php:class:`Query` class $template_load_data. +2. Add public method allowing to specify necessary parameters. +3. Re-use existing methods/template tags if you can. +4. Create _render method if your tag rendering is complex. + +So to implement our task, you might need a class like this:: + + use \atk4\dsql\Exception; + class Query_MySQL extends \atk4\dsql\Query_MySQL + { + protected $template_load_data = 'load data local infile [file] into table [table]'; + + public function file($file) + { + if (!is_readable($file)) { + throw Exception(['File is not readable', 'file'=>$file]); + } + $this['file'] = $file; + } + + public function loadData() + { + return $this->mode('load_data')->execute(); + } + } + +Then to use your new statement, you can do:: + + $c->dsql()->file('abc.csv')->loadData(); + +Manual Query Execution +====================== + +If you are not satisfied with :php:meth:`Expression::execute` you can execute +query yourself. + +1. :php:meth:`Expression::render` query, then send it into PDO::prepare(); +2. use new $statement to bindValue with the contents of :php:attr:`Expression::params`; +3. set result fetch mode and parameters; +4. execute() your statement + + + +Exception Class +=============== +DSQL slightly extends and improves :php:class:`Exception` class + +.. php:class:: Exception + +The main goal of the new exception is to be able to accept additional +information in addition to the message. We realize that often $e->getMessage() +will be localized, but if you stick some variables in there, this will no longer +be possible. You also risk injection or expose some sensitive data to the user. + +.. php:method:: __construct($message, $code) + + Create new exception + + :param string|array $message: Describes the problem + :param int $code: Error code + +Usage:: + + throw new atk4\dsql\Exception('Hello'); + + throw new atk4\dsql\Exception(['File is not readable', 'file'=>$file]); + +When displayed to the user the exception will hide parameter for $file, but you +still can get it if you really need it: + +.. php:method:: getParams() + + Return additional parameters, that might be helpful to find error. + + :returns: array + +Any DSQL-related code must always throw atk4\dsql\Exception. Query-related +errors will generate PDO exceptions. If you use a custom connection and doing +some vendor-specific operations, you may also throw other vendor-specific +exceptions. diff --git a/docs/db/connection.rst b/docs/db/connection.rst new file mode 100644 index 00000000..da472004 --- /dev/null +++ b/docs/db/connection.rst @@ -0,0 +1,82 @@ + +.. _connect: + +========== +Connection +========== + +DSQL supports various database vendors natively but also supports 3rd party +extensions. +For current status on database support see: :ref:`databases`. + + +.. php:class:: Connection + +Connection class is handy to have if you plan on building and executing +queries in your application. It's more appropriate to store +connection in a global variable or global class:: + + $app->db = atk4\dsql\Connection::connect($dsn, $user, $pass); + + +.. php:staticmethod:: connect($dsn, $user = null, $password = null, $args = []) + + Determine which Connection class should be used for specified $dsn, + create new object of this connection class and return. + + :param string $dsn: DSN, see http://php.net/manual/en/ref.pdo-mysql.connection.php + :param string $user: username + :param string $password: password + :param array $args: Other default properties for connection class. + :returns: new Connection + + +This should allow you to access this class from anywhere and generate either +new Query or Expression class:: + + $query = $app->db->dsql(); + + // or + + $expr = $app->db->expr('show tables'); + + +.. php:method:: dsql($args) + + Creates new Query class and sets :php:attr:`Query::connection`. + + :param array $args: Other default properties for connection class. + :returns: new Query + +.. php:method:: expr($template, $args) + + Creates new Expression class and sets :php:attr:`Expression::connection`. + + :param string $args: Other default properties for connection class. + :param array $args: Other default properties for connection class. + :returns: new Expression + + +Here is how you can use all of this together:: + + + $dsn = 'mysql:host=localhost;port=3307;dbname=testdb'; + + $c = atk4\dsql\Connection::connect($dsn, 'root', 'root'); + $expr = $c -> expr("select now()"); + + echo "Time now is : ". $expr; + +:php:meth:`connect` will determine appropriate class that can be used for this +DSN string. This can be a PDO class or it may try to use a 3rd party connection +class. + +Connection class is also responsible for executing queries. This is only used +if you connect to vendor that does not use PDO. + +.. php:method:: execute(Expression $expr) + + Creates new Expression class and sets :php:attr:`Expression::connection`. + + :param Expression $expr: Expression (or query) to execute + :returns: PDOStatement, Iterable object or Generator. diff --git a/docs/db/expressions.rst b/docs/db/expressions.rst new file mode 100644 index 00000000..0d0014a6 --- /dev/null +++ b/docs/db/expressions.rst @@ -0,0 +1,381 @@ +.. _expr: + +.. php:class:: Expression + +=========== +Expressions +=========== + +Expression class implements a flexible way for you to define any custom +expression then execute it as-is or as a part of another query or expression. +Expression is supported anywhere in DSQL to allow you to express SQL syntax +properly. + +Quick Example:: + + $query -> where('time', $query->expr( + 'between "[]" and "[]"', + [$from_time, $to_time] + )); + + // Produces: .. where `time` between :a and :b + +Another use of expression is to supply field instead of value and vice versa:: + + $query -> where($query->expr( + '[] between time_from and time_to', + [$time] + )); + + // Produces: where :a between time_from and time_to + +Yet another curious use for the DSQL library is if you have certain object in +your ORM implementing :php:class:`Expressionable` interface. Then you can also +use it within expressions:: + + $query -> where($query->expr( + '[] between [] and []', + [$time, $model->getElement('time_form'), $model->getElement('time_to')] + )); + + // Produces: where :a between `time_from` and `time_to` + +.. todo:: + add more info or more precise example of Expressionable interface usage. + + +Another uses for expressions could be: + + - Sub-Queries + - SQL functions, e.g. IF, CASE + - nested AND / OR clauses + - vendor-specific queries - "describe table" + - non-traditional constructions , UNIONS or SELECT INTO + +Properties, Arguments, Parameters +==================================== + +Be careful when using those similar terms as they refer to different things: + + - Properties refer to object properties, e.g. `$expr->template`, + see :ref:`properties` + - Arguments refer to template arguments, e.g. `select * from [table]`, + see :ref:`expression-template` + - Parameters refer to the way of passing user values within a query + `where id=:a` and are further explained below. + +Parameters +---------- + +Because some values are un-safe to use in the query and can contain dangerous +values they are kept outside of the SQL query string and are using +`PDO's bindParam `_ +instead. DSQL can consist of multiple objects and each object may have +some parameters. During `rendering`_ those parameters are joined together to +produce one complete query. + +.. php:attr:: params + + This public property will contain the actual values of all the parameters. + When multiple queries are merged together, their parameters are + `interlinked `_. + + +Creating Expression +=================== + +:: + + use atk4\dsql\Expression; + + $expr = new Expression("NOW()"); + +You can also use :php:meth:`expr()` method to create expression, in which case +you do not have to define "use" block:: + + $query -> where('time', '>', $query->expr('NOW()')); + + // Produces: .. where `time` > NOW() + +You can specify some of the expression properties through first argument of the +constructor:: + + $expr = new Expression(["NOW()", 'connection' => $pdo]); + +:ref:`Scroll down ` for full list of properties. + +.. _expression-template: + +Expression Template +=================== + +When you create a template the first argument is the template. It will be stored +in :php:attr:`$template` property. Template string can contain arguments in a +square brackets: + + - ``coalesce([], [])`` is same as ``coalesce([0], [1])`` + - ``coalesce([one], [two])`` + +Arguments can be specified immediately through an array as a second argument +into constructor or you can specify arguments later:: + + $expr = new Expression( + "coalesce([name], [surname])", + ['name' => $name, 'surname' => $surname] + ); + + // is the same as + + $expr = new Expression("coalesce([name], [surname])"); + $expr['name'] = $name; + $expr['surname'] = $surname; + +Nested expressions +================== + +Expressions can be nested several times:: + + $age = new Expression("coalesce([age], [default_age])"); + $age['age'] = new Expression("year(now()) - year(birth_date)"); + $age['default_age'] = 18; + + $query -> table('user') -> field($age, 'calculated_age'); + + // select coalesce(year(now()) - year(birth_date), :a) `calculated_age` from `user` + +When you include one query into another query, it will automatically take care +of all user-defined parameters (such as value `18` above) which will make sure +that SQL injections could not be introduced at any stage. + +Rendering +========= + +An expression can be rendered into a valid SQL code by calling render() method. +The method will return a string, however it will use references for `parameters`_. + +.. php:method:: render() + + Converts :php:class:`Expression` object to a string. Parameters are + replaced with :a, :b, etc. Their original values can be found in + :php:attr:`params`. + + +Executing Expressions +===================== + +If your expression is a valid SQL query, (such as ```show databases```) you +might want to execute it. Expression class offers you various ways to execute +your expression. Before you do, however, you need to have :php:attr:`$connection` +property set. (See `Connecting to Database` on more details). In short the +following code will connect your expression with the database:: + + $expr = new Expression('connection'=>$pdo_dbh); + +If you are looking to use connection :php:class:`Query` class, you may want to +consider using a proper vendor-specific subclass:: + + $query = new Query_MySQL('connection'=>$pdo_dbh); + + +If your expression already exist and you wish to associate it with connection +you can simply change the value of :php:attr:`$connection` property:: + + $expr -> connection = $pdo_dbh; + +Finally, you can pass connection class into :php:meth:`execute` directly. + +.. php:method:: execute($connection = null) + + Executes expression using current database connection or the one you + specify as the argument:: + + $stmt = $expr -> execute($pdo_dbh); + + returns `PDOStamement `_ if + you have used `PDO `_ class or + ResultSet if you have used Connection. + +.. todo:: + + Complete this when ResultSet and Connection are implemented + + +.. php:method:: expr($properties, $arguments) + + Creates a new :php:class:`Expression` object that will inherit current + :php:attr:`$connection` property. Also if you are creating a + vendor-specific expression/query support, this method must return + instance of your own version of Expression class. + + The main principle here is that the new object must be capable of working + with database connection. + +.. php:method:: get() + + Executes expression and return whole result-set in form of array of hashes:: + + $data = new Expression([ + 'connection' => $pdo_dbh, + 'template' => 'show databases' + ])->get(); + echo json_encode($data); + + The output would be + + .. code-block:: json + + [ + { "Database": "mydb1" }, + { "Database": "mysql" }, + { "Database": "test" } + ] + + +.. php:method:: getRow() + + Executes expression and returns first row of data from result-set as a hash:: + + $data = new Expression([ + 'connection' => $pdo_dbh, + 'template' => 'SELECT @@global.time_zone, @@session.time_zone' + ])->getRow() + + echo json_encode($data); + + The output would be + + .. code-block:: json + + { "@@global.time_zone": "SYSTEM", "@@session.time_zone": "SYSTEM" } + +.. php:method:: getOne() + + Executes expression and return first value of first row of data from + result-set:: + + $time = new Expression([ + 'connection' => $pdo_dbh, + 'template' => 'now()' + ])->getOne(); + +Magic an Debug Methods +====================== + +.. php:method:: __toString() + + You may use :php:class:`Expression` or :php:class:`Query` as a string. It + will be automatically executed when being cast by executing :php:meth:`getOne`. + Because the `__toString() `_ + is not allowed to throw exceptions we encourage you not to use this format. + +.. php:method:: __debugInfo() + + This method is used to prepare a sensible information about your query + when you are executing ``var_dump($expr)``. The output will be HTML-safe. + +.. php:method:: debug() + + Calling this method will set :php:attr:`debug` into ``true`` and the further + execution to :php:meth:`render` will also attempt to echo query. + +.. php:method:: getDebugQuery($html = false) + + Outputs query as a string by placing parameters into their respective + places. The parameters will be escaped, but you should still avoid using + generated query as it can potentially make you vulnerable to SQL injection. + + This method will use HTML formatting if argument is passed. + +In order for HTML parsing to work and to make your debug queries better +formatted, install `sql-formatter`:: + + composer require jdorn/sql-formatter + + +Escaping Methods +================ + +The following methods are useful if you're building your own code for rendering +parts of the query. You must not call them in normal circumstances. + +.. php:method:: _consume($sql_code) + + Makes `$sql_code` part of `$this` expression. Argument may be either a string + (which will be escaped) or another :php:class:`Expression` or :php:class:`Query`. + If specified :php:class:`Query` is in "select" mode, then it's automatically + placed inside brackets:: + + $query->_consume('first_name'); // `first_name` + $query->_consume($other_query); // will merge parameters and return string + +.. php:method:: escape($sql_code) + + Creates new expression where $sql_code appears escaped. Use this method as a + conventional means of specifying arguments when you think they might have + a nasty back-ticks or commas in the field names. I generally **discourage** + you from using this method. Example use would be:: + + $query->field('foo,bar'); // escapes and adds 2 fields to the query + $query->field($query->escape('foo,bar')); // adds field `foo,bar` to the query + $query->field(['foo,bar']); // adds single field `foo,bar` + + $query->order('foo desc'); // escapes and add `foo` desc to the query + $query->field($query->escape('foo desc')); // adds field `foo desc` to the query + $query->field(['foo desc']); // adds `foo` desc anyway + +.. php:method:: _escape($sql_code) + + Always surrounds `$sql code` with back-ticks. + + This escaping method is automatically used for `{...}` expression template tags . + +.. php:method:: _escapeSoft($sql_code) + + Surrounds `$sql code` with back-ticks. + + This escaping method is automatically used for `{{...}}` expression template tags . + + It will smartly escape table.field type of strings resulting in `table`.`field`. + + Will do nothing if it finds "*", "`" or "(" character in `$sql_code`:: + + $query->_escapeSoft('first_name'); // `first_name` + $query->_escapeSoft('first.name'); // `first`.`name` + $query->_escapeSoft('(2+2)'); // (2+2) + $query->_escapeSoft('*'); // * + +.. php:method:: _param($value) + + Converts value into parameter and returns reference. Used only during query + rendering. Consider using :php:meth:`_consume()` instead, which will also + handle nested expressions properly. + + This escaping method is automatically used for `[...]` expression template tags . + + +.. _properties: + +Other Properties +================ + +.. php:attr:: template + + Template which is used when rendering. + You can set this with either `new Expression("show tables")` + or `new Expression(["show tables"])` + or `new Expression(["template" => "show tables"])`. + +.. php:attr:: connection + + PDO connection object or any other DB connection object. + +.. php:attr:: paramBase + + Normally parameters are named :a, :b, :c. You can specify a different + param base such as :param_00 and it will be automatically increased + into :param_01 etc. + +.. php:attr:: debug + + If true, then next call of :php:meth:`execute` will ``echo`` results + of :php:meth:`getDebugQuery`. diff --git a/docs/db/extensions.rst b/docs/db/extensions.rst new file mode 100644 index 00000000..164325de --- /dev/null +++ b/docs/db/extensions.rst @@ -0,0 +1,40 @@ +.. _databases: + +Vendor support and Extensions +============================= + +=========== ========= ======== ============ +Vendor Support PDO Dependency +=========== ========= ======== ============ +MySQL Full mysql: native, PDO +SQLite Full sqlite: native, PDO +Oracle Untested oci: native, PDO +PostgreSQL Untested pgsql: native, PDO +MSSQL Untested mssql: native, PDO +=========== ========= ======== ============ + +.. note:: + + Most PDO vendors should work out of the box + +Other Interesting Drivers +------------------------- + +===================== ========= ======== ============ +Class Support PDO Dependency +===================== ========= ======== ============ +Connection_Dumper Full dumper: native, Proxy +Connection_Counter Full counter: native, Proxy +===================== ========= ======== ============ + + +3rd party vendor support +------------------------- + +===================== ========= ========= ============================ +Class Support PDO Dependency +===================== ========= ========= ============================ +Connection_MyVendor Full myvendor: http://github/test/myvendor +===================== ========= ========= ============================ + +See :ref:`new_vendor` for more details on how to add support for your driver. diff --git a/docs/db/images/agiletoolkit.png b/docs/db/images/agiletoolkit.png new file mode 100644 index 00000000..c4d66521 Binary files /dev/null and b/docs/db/images/agiletoolkit.png differ diff --git a/docs/db/images/smgit_configure_branches.png b/docs/db/images/smgit_configure_branches.png new file mode 100644 index 00000000..4c12f51b Binary files /dev/null and b/docs/db/images/smgit_configure_branches.png differ diff --git a/docs/db/images/smgit_configure_git-flow.png b/docs/db/images/smgit_configure_git-flow.png new file mode 100644 index 00000000..7c62e45a Binary files /dev/null and b/docs/db/images/smgit_configure_git-flow.png differ diff --git a/docs/db/images/smgit_file_compare.png b/docs/db/images/smgit_file_compare.png new file mode 100644 index 00000000..b5e602f8 Binary files /dev/null and b/docs/db/images/smgit_file_compare.png differ diff --git a/docs/db/images/smgit_log.png b/docs/db/images/smgit_log.png new file mode 100644 index 00000000..5c245a54 Binary files /dev/null and b/docs/db/images/smgit_log.png differ diff --git a/docs/db/images/smgit_push.png b/docs/db/images/smgit_push.png new file mode 100644 index 00000000..8c6bf37e Binary files /dev/null and b/docs/db/images/smgit_push.png differ diff --git a/docs/db/index.rst b/docs/db/index.rst new file mode 100644 index 00000000..ca7df89e --- /dev/null +++ b/docs/db/index.rst @@ -0,0 +1,31 @@ +.. Pluf DB documentation master file, created by + sphinx-quickstart on Thu Feb 4 16:28:49 2016. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to DSQL's documentation! +================================ + +Contents: + +.. toctree:: + :maxdepth: 3 + + overview + quickstart + connection + expressions + queries + results + transactions + advanced + extensions + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/db/overview.rst b/docs/db/overview.rst new file mode 100644 index 00000000..df47958b --- /dev/null +++ b/docs/db/overview.rst @@ -0,0 +1,182 @@ +======== +Overview +======== + +Pluf DB is a dynamic SQL query builder. You can write multi-vendor queries in PHP +profiting from better security, clean syntax and most importantly – sub-query +support. With Pluf DB you stay in control of when queries are executed and what +data is transmitted. Pluf DB is easily composable – build one query and use it as +a part of other query. + + +Goals of Pluf DB +============= + + - simple and concise syntax + - consistently scalable (e.g. 5 levels of sub-queries, 10 with joins and 15 parameters? no problem) + - "One Query" paradigm + - support for PDO vendors as well as NoSQL databases (with query language similar to SQL) + - small code footprint (over 50% less than competing frameworks) + - no dependencies + - follows design paradigms: + - "`PHP the Agile way `_" + - "`Functional ORM `_" + - "`Open to extend `_" + - "`Vendor Transparency `_" + +Pluf DB by example +=============== +The simplest way to explain Pluf DB is by example:: + + $query = new Pluf\Db\Query(); + $query ->table('employees') + ->where('birth_date','1961-05-02') + ->field('count(*)'); + echo "Employees born on May 2, 1961: " . $query->getOne(); + +The above code will execute the following query: + +.. code-block:: sql + + select count(*) from `salary` where `birth_date` = :a + :a = "1961-05-02" + +Pluf DB can also execute queries with multiple sub-queries, joins, expressions +grouping, ordering, unions as well as queries on result-set. + + - See :ref:`quickstart` if you would like to start learning DSQL. + - See https://github.com/atk4/dsql-primer for various working + examples of using Pluf DB with a real data-set. + + +Pluf DB is Part of Pluf Framework +============================= +Pluf DB is a stand-alone and lightweight library with no dependencies and can be +used in any PHP project, big or small. + +.. figure:: images/agiletoolkit.png + :alt: Agile Toolkit Stack + +Pluf DB is also a part of `Pluf`_ framework and works best with +`Pluf Models`_. Your project may benefit from a higher-level data abstraction +layer, so be sure to look at the rest of the suite. + +.. _Pluf: http://pluf.ir/ +.. _Pluf Models: https://pluf.ir/wb/products/module/pluf-models + + +Requirements +============ + +#. PHP 7.4 and above + +.. _installation: + +Installation +============ + +The recommended way to install Pluf is with +`Composer `_. Composer is a dependency management tool +for PHP that allows you to declare the dependencies your project has and it +automatically installs them into your project. + + +.. code-block:: bash + + # Install Composer + curl -sS https://getcomposer.org/installer | php + php composer.phar require pluf/core + +You can specify Pluf as a project or module dependency in composer.json: + +.. code-block:: js + + { + "require": { + "pluf/core": "*" + } + } + +After installing, you need to require Composer's autoloader in your PHP file:: + + require 'vendor/autoload.php'; + +You can find out more on how to install Composer, configure auto-loading, and +other best-practices for defining dependencies at +`getcomposer.org `_. + + +Getting Started +=============== + +Continue reading :ref:`quickstart` where you will learn about basics of DSQL +and how to use it to it's full potential. + +Contributing +============ + +Guidelines +---------- + +1. Pluf utilizes PSR-1, PSR-2, PSR-4, and PSR-7. +2. Pluf is meant to be lean and fast with very few dependencies. This means + that not every feature request will be accepted. +3. All pull requests must include unit tests to ensure the change works as + expected and to prevent regressions. +4. All pull requests must include relevant documentation or amend the existing + documentation if necessary. + +Review and Approval +------------------- + +1. All code must be submitted through pull requests on GitHub +2. Any of the project managers may Merge your pull request, but it must not be + the same person who initiated the pull request. + + +Running the tests +----------------- + +In order to contribute, you'll need to checkout the source from GitHub and +install Pluf dependencies using Composer: + +.. code-block:: bash + + git clone https://github.com/pluf/core.git + cd core && curl -s http://getcomposer.org/installer | php && ./composer.phar install --dev + +Pluf is unit tested with PHPUnit. Run the tests using the command: + +.. code-block:: bash + + vendor/bin/phpunit + +There are also vendor-specific test-scripts which will require you to +set database. To run them: + +.. code-block:: bash + + # All unit tests including SQLite database engine tests + vendor/bin/phpunit --config phpunit.xml + + # MySQL database engine tests + vendor/bin/phpunit --config phpunit-mysql.xml + +Look inside these the .xml files for further information and connection details. + +Reporting a security vulnerability +================================== + +We want to ensure that Pluf is a secure library for everyone. If you've +discovered a security vulnerability in Pluf, we appreciate your help in +disclosing it to us in a `responsible manner `_. + +Publicly disclosing a vulnerability can put the entire community at risk. If +you've discovered a security concern, please email us at +info@pluf.ir. We'll work with you to make sure that we understand +the scope of the issue, and that we fully address your concern. We consider +correspondence sent to info@pluf.ir our highest priority, and work +to address any issues that arise as quickly as possible. + +After a security vulnerability has been corrected, a security hot-fix release +will be deployed as soon as possible. diff --git a/docs/db/queries.rst b/docs/db/queries.rst new file mode 100644 index 00000000..551c9830 --- /dev/null +++ b/docs/db/queries.rst @@ -0,0 +1,906 @@ +.. _query: + +.. php:class:: Query + +======= +Queries +======= + +Query class represents your SQL query in-the-making. Once you create object of +the Query class, call some of the methods listed below to modify your query. To +actually execute your query and start retrieving data, see :ref:`fetching-result` +section. + +You should use :ref:`connect` if possible to create your query objects. All +examples below are using `$c->query()` method which generates Query linked to +your established database connection. + +Once you have a query object you can execute modifier methods such as +:php:meth:`field()` or :php:meth:`table()` which will change the way how your +query will act. + +Once the query is defined, you can either use it inside another query or +expression or you can execute it in exchange for result set. + +Quick Example:: + + + $query = $c->query(); + + $query -> field('name'); + $query -> where('id', 123); + + $name = $query -> getOne(); + + +Method invocation principles +============================ + +Methods of Query are designed to be flexible and concise. Most methods have a +variable number of arguments and some arguments can be skipped:: + + $query -> where('id', 123); + $query -> where('id', '=', 123); // the same + +Most methods will accept :php:class:`Expression` or strings. Strings are +escaped or quoted (depending on type of argument). By using :php:class:`Expression` +you can bypass the escaping. + +There are 2 types of escaping: + + * :php:meth:`Expression::_escape()`. Used for field and table names. Surrounds name with *`*. + * :php:meth:`Expression::_param()`. Will convert value into parameter and replace with *:a* + +In the next example $a is escaped but $b is parametrized:: + + $query -> where('a', 'b'); + + // where `a` = "b" + +If you want to switch places and execute *where "b" = `a`*, then you can resort +to Expressions:: + + $query -> where($c->expr('{} = []', ['b', 'a'])); + +Parameters which you specify into Expression will be preserved and linked into +the `$query` properly. + +.. _query-modes: + +Query Modes +=========== + +When you create new Query it always start in "select" mode. You can switch +query to a different mode using :php:meth:`mode`. Normally you shouldn't bother +calling this method and instead use one of the following methods. +They will switch the query mode for you and execute query: + +.. php:method:: select() + + Switch back to "select" mode and execute `select` statement. + + See `Modifying Select Query`_. + +.. php:method:: insert() + + Switch to `insert` mode and execute statement. + + See `Insert and Replace query`_. + +.. php:method:: update() + + Switch to `update` mode and execute statement. + + See `Update Query`_. + +.. php:method:: replace() + + Switch to `replace` mode and execute statement. + + See `Insert and Replace query`_. + +.. php:method:: delete() + + Switch to `delete` mode and execute statement. + + See `Delete Query`_. + +.. php:method:: truncate() + + Switch to `truncate` mode and execute statement. + +If you don't switch the mode, your Query remains in select mode and you can +fetch results from it anytime. + +The pattern of defining arguments for your Query and then executing allow you +to re-use your query efficiently:: + + $data = ['name'=>'John', 'surname'=>'Smith'] + + $query = $c->query(); + $query + -> where('id', 123) + -> field('id') + -> table('user') + -> set($data) + ; + + $row = $query->getRow(); + + if ($row) { + $query + ->set('revision', $query->expr('revision + 1')) + ->update() + ; + } else { + $query + ->set('revision', 1) + ->insert(); + } + +The example above will perform a select query first: + + - `select id from user where id=123` + +If a single row can be retrieved, then the update will be performed: + + - `update user set name="John", surname="Smith", revision=revision+1 where id=123` + +Otherwise an insert operation will be performed: + + - `insert into user (name,surname,revision) values ("John", "Smith", 1)` + +Chaining +======== + +Majority of methods return `$this` when called, which makes it pretty +convenient for you to chain calls by using `->fx()` multiple times as +illustrated in last example. + +You can also combine creation of the object with method chaining:: + + $age = $c->query()->table('user')->where('id', 123)->field('age')->getOne(); + +Using query as expression +========================= + +You can use query as expression where applicable. The query will get a special +treatment where it will be surrounded in brackets. Here are few examples:: + + $q = $c->query() + ->table('employee'); + + $q2 = $c->query() + ->field('name') + ->table($q); + + $q->get(); + +This query will perform `select name from (select * from employee)`:: + + $q1 = $c->query() + ->table('sales') + ->field('date') + ->field('amount', null, 'debit'); + + $q2 = $c->query() + ->table('purchases') + ->field('date') + ->field('amount', null, 'credit'); + + $u = $c->query("[] union []", [$q1, $q2]); + + $q = $c->query() + ->field('date,debit,credit') + ->table($u, 'derrivedTable') + ; + + $q->get(); + +This query will perform union between 2 table selects resulting in the following +query: + +.. code-block:: sql + + select `date`,`debit`,`credit` from ( + (select `date`,`amount` `debit` from `sales`) union + (select `date`,`amount` `credit` from `purchases`) + ) `derrivedTable` + +Modifying Select Query +====================== + +Setting Table +------------- + +.. php:method:: table($table, $alias) + + Specify a table to be used in a query. + + :param mixed $table: table such as "employees" + :param mixed $alias: alias of table + :returns: $this + +This method can be invoked using different combinations of arguments. +Follow the principle of specifying the table first, and then optionally provide +an alias. You can specify multiple tables at the same time by using comma or +array (although you won't be able to use the alias there). +Using keys in your array will also specify the aliases. + +Basic Examples:: + + $c->query()->table('user'); + // SELECT * from `user` + + $c->query()->table('user','u'); + // aliases table with "u" + // SELECT * from `user` `u` + + $c->query()->table('user')->table('salary'); + // specify multiple tables. Don't forget to link them by using "where" + // SELECT * from `user`, `salary` + + $c->query()->table(['user','salary']); + // identical to previous example + // SELECT * from `user`, `salary` + + $c->query()->table(['u'=>'user','s'=>'salary']); + // specify aliases for multiple tables + // SELECT * from `user` `u`, `salary` `s` + +Inside your query table names and aliases will always be surrounded by backticks. +If you want to use a more complex expression, use :php:class:`Expression` as +table:: + + $c->query()->table( + $c->expr('(SELECT id FROM user UNION select id from document)'), + 'tbl' + ); + // SELECT * FROM (SELECT id FROM user UNION SELECT id FROM document) `tbl` + +Finally, you can also specify a different query instead of table, by simply +passing another :php:class:`Query` object:: + + $sub_q = $c->query(); + $sub_q -> table('employee'); + $sub_q -> where('name', 'John'); + + $q = $c->query(); + $q -> field('surname'); + $q -> table($sub_q, 'sub'); + + // SELECT `surname` FROM (SELECT * FROM `employee` WHERE `name` = :a) `sub` + +Method can be executed several times on the same Query object. + +Setting Fields +-------------- + +.. php:method:: field($fields, $alias = null) + + Adds additional field that you would like to query. If never called, will + default to :php:attr:`defaultField`, which normally is `*`. + + This method has several call options. $field can be array of fields and + also can be an :php:class:`Expression` or :php:class:`Query` + + :param string|array|object $fields: Specify list of fields to fetch + :param string $alias: Optionally specify alias of field in resulting query + :returns: $this + +Basic Examples:: + + $query = new Query(); + $query->table('user'); + + $query->field('first_name'); + // SELECT `first_name` from `user` + + $query->field('first_name,last_name'); + // SELECT `first_name`,`last_name` from `user` + + $query->field('employee.first_name') + // SELECT `employee`.`first_name` from `user` + + $query->field('first_name','name') + // SELECT `first_name` `name` from `user` + + $query->field(['name'=>'first_name']) + // SELECT `first_name` `name` from `user` + + $query->field(['name'=>'employee.first_name']); + // SELECT `employee`.`first_name` `name` from `user` + +If the first parameter of field() method contains non-alphanumeric values +such as spaces or brackets, then field() will assume that you're passing an +expression:: + + $query->field('now()'); + + $query->field('now()', 'time_now'); + +You may also pass array as first argument. In such case array keys will be +used as aliases (if they are specified):: + + $query->field(['time_now'=>'now()', 'time_created']); + // SELECT now() `time_now`, `time_created` ... + + $query->field($query->dsql()->table('user')->field('max(age)'), 'max_age'); + // SELECT (SELECT max(age) from user) `max_age` ... + +Method can be executed several times on the same Query object. + +Setting where and having clauses +--------------------- + +.. php:method:: where($field, $operation, $value) + + Adds WHERE condition to your query. + + :param mixed $field: field such as "name" + :param mixed $operation: comparison operation such as ">" (optional) + :param mixed $value: value or expression + :returns: $this + +.. php:method:: having($field, $operation, $value) + + Adds HAVING condition to your query. + + :param mixed $field: field such as "name" + :param mixed $operation: comparison operation such as ">" (optional) + :param mixed $value: value or expression + :returns: $this + + +Both methods use identical call interface. They support one, two or three +argument calls. + +Pass string (field name), :php:class:`Expression` or even :php:class:`Query` as +first argument. If you are using string, you may end it with operation, such as +"age>" or "parent_id is not" DSQL will recognize <, >, =, !=, <>, is, is not. + +If you haven't specified parameter as a part of $field, specify it through a +second parameter - $operation. If unspecified, will default to '='. + +Last argument is value. You can specify number, string, array, expression or +even null (specifying null is not the same as omitting this argument). +This argument will always be parameterized unless you pass expression. +If you specify array, all elements will be parametrized individually. + +Starting with the basic examples:: + + $q->where('id', 1); + $q->where('id', '=', 1); // same as above + + $q->where('id>', 1); + $q->where('id', '>', 1); // same as above + + $q->where('id', 'is', null); + $q->where('id', null); // same as above + + $q->where('now()', 1); // will not use backticks + $q->where($c->expr('now()'),1); // same as above + + $q->where('id', [1,2]); // renders as id in (1,2) + +You may call where() multiple times, and conditions are always additive (uses AND). +The easiest way to supply OR condition is to specify multiple conditions +through array:: + + $q->where([['name', 'like', '%john%'], ['surname', 'like', '%john%']]); + // .. WHERE `name` like '%john%' OR `surname` like '%john%' + +You can also mix and match with expressions and strings:: + + $q->where([['name', 'like', '%john%'], 'surname is null']); + // .. WHERE `name` like '%john%' AND `surname` is null + + $q->where([['name', 'like', '%john%'], new Expression('surname is null')]); + // .. WHERE `name` like '%john%' AND surname is null + +There is a more flexible way to use OR arguments: + +.. php:method:: orExpr() + + Returns new Query object with method "where()". When rendered all clauses + are joined with "OR". + +.. php:method:: andExpr() + + Returns new Query object with method "where()". When rendered all clauses + are joined with "OR". + +Here is a sophisticated example:: + + $q = $c->query(); + + $q->table('employee')->field('name'); + $q->where('deleted', 0); + $q->where( + $q + ->orExpr() + ->where('a', 1) + ->where('b', 1) + ->where( + $q->andExpr() + ->where('a', 2) + ->where('b', 2) + ) + ); + +The above code will result in the following query: + +.. code-block:: sql + + select + `name` + from + `employee` + where + deleted = 0 and + (`a` = :a or `b` = :b or (`a` = :c and `b` = :d)) + +Technically orExpr() generates a yet another object that is composed +and renders its calls to where() method:: + + $q->having( + $q + ->orExpr() + ->where('a', 1) + ->where('b', 1) + ); + +.. code-block:: sql + + having + (`a` = :a or `b` = :b) + + +Grouping results by field +------------------------- + +.. php:method:: group($field) + + Group by functionality. Simply pass either field name as string or + :class:`Expression` object. + + :param mixed $field: field such as "name" + :returns: $this + +The "group by" clause in SQL query accepts one or several fields. It can also +accept expressions. You can call `group()` with one or several comma-separated +fields as a parameter or you can specify them in array. Additionally you can +mix that with :php:class:`Expression` or :php:class:`Expressionable` objects. + +Few examples:: + + $q->group('gender'); + + $q->group('gender,age'); + + $q->group(['gender', 'age']); + + $q->group('gender')->group('age'); + + $q->group(new Expression('year(date)')); + +Method can be executed several times on the same Query object. + + +Concatenate group of values +--------------------------- + +.. php:method:: groupConcat($field, $separator = ',') + + Quite often when you use `group by` in your queries you also would like to + concatenate group of values. + + :param mixed $field Field name or object + :param string $separator Optional separator to use. It's comma by default + +Different SQL engines have different syntax for doing this. +In MySQL it's group_concat(), in Oracle it's listagg, but in PgSQL it's string_agg. +That's why we have this method which will take care of this. + + $q->groupConcat('phone', ';'); + // group_concat('phone', ';') + +If you need to add more parameters for this method, then you can extend this class +and overwrite this simple method to support expressions like this, for example: + + group_concat('phone' order by 'date' desc seprator ';') + + +Joining with other tables +------------------------- + +.. php:method:: join($foreign_table, $master_field, $join_kind) + + Join results with additional table using "JOIN" statement in your query. + + :param string|array $foreign_table: table to join (may include field and alias) + :param mixed $master_field: main field (and table) to join on or Expression + :param string $join_kind: 'left' (default), 'inner', 'right' etc - which join type to use + :returns: $this + +When joining with a different table, the results will be stacked by the SQL +server so that fields from both tables are available. The first argument can +specify the table to join, but may contain more information:: + + $q->join('address'); // address.id = address_id + // JOIN `address` ON `address`.`id`=`address_id` + + $q->join('address a'); // specifies alias for the table + // JOIN `address` `a` ON `address`.`id`=`address_id` + + $q->join('address.user_id'); // address.user_id = id + // JOIN `address` ON `address`.`user_id`=`id` + +You can also pass array as a first argument, to join multiple tables:: + + $q->table('user u'); + $q->join(['a'=>'address', 'c'=>'credit_card', 'preferences']); + +The above code will join 3 tables using the following query syntax: + +.. code-block:: sql + + join + address as a on a.id = u.address_id + credit_card as c on c.id = u.credit_card_id + preferences on preferences.id = u.preferences_id + +However normally you would have `user_id` field defined in your supplementary +tables so you need a different syntax:: + + $q->table('user u'); + $q->join([ + 'a'=>'address.user_id', + 'c'=>'credit_card.user_id', + 'preferences.user_id' + ]); + +The second argument to join specifies which existing table/field is +used in `on` condition:: + + $q->table('user u'); + $q->join('user boss', 'u.boss_user_id'); + // JOIN `user` `boss` ON `boss`.`id`=`u`.`boss_user_id` + +By default the "on" field is defined as `$table."_id"`, as you have seen in the +previous examples where join was done on "address_id", and "credit_card_id". +If you have specified field explicitly in the foreign field, then the "on" field +is set to "id", like in the example above. + +You can specify both fields like this:: + + $q->table('employees'); + $q->join('salaries.emp_no', 'emp_no'); + +If you only specify field like this, then it will be automatically prefixed with +the name or alias of your main table. If you have specified multiple tables, +this won't work and you'll have to define name of the table explicitly:: + + $q->table('user u'); + $q->join('user boss', 'u.boss_user_id'); + $q->join('user super_boss', 'boss.boss_user_id'); + +The third argument specifies type of join and defaults to "left" join. You can +specify "inner", "straight" or any other join type that your database support. + +Method can be executed several times on the same Query object. + +Joining on expression +````````````````````` + +For a more complex join conditions, you can pass second argument as expression:: + + $q->table('user', 'u'); + $q->join('address a', new Expression('a.name like u.pattern')); + + +Use WITH cursors +--------------------------- + +.. php:method:: with(Query $cursor, string $alias, ?array $fields = null, bool $recursive = false) + + If you want to add `WITH` cursor statement in your SQL, then use this method. + First parameter defines sub-query to use. Second parameter defines alias of this cursor. + By using third, optional argument you can set aliases for columns in cursor. + And finally forth, optional argument set if cursors will be recursive or not. + + You can add more than one cursor in your query. + + Did you know: you can use these cursors when joining your query to other tables. Just join cursor instead. + +.. php:method:: withRecursive(Query $cursor, string $alias, ?array $fields = null) + + Same as :php:meth:`with()`, but always sets it as recursive. + + Keep in mind that if any of cursors added in your query will be recursive, then all cursors will + be set recursive. That's how SQL want it to be. + + Example:: + + $quotes = $q->table('quotes') + ->field('emp_id') + ->field($q->expr('sum([])', ['total_net'])) + ->group('emp_id'); + $invoices = $q()->table('invoices') + ->field('emp_id') + ->field($q->expr('sum([])', ['total_net'])) + ->group('emp_id'); + $employees = $q + ->with($quotes, 'q', ['emp','quoted']) + ->with($invoices, 'i', ['emp','invoiced']) + ->table('employees') + ->join('q.emp') + ->join('i.emp') + ->field(['name', 'salary', 'q.quoted', 'i.invoiced']); + + This generates SQL below: + +.. code-block:: sql + + with + `q` (`emp`,`quoted`) as (select `emp_id`,sum(`total_net`) from `quotes` group by `emp_id`), + `i` (`emp`,`invoiced`) as (select `emp_id`,sum(`total_net`) from `invoices` group by `emp_id`) + select `name`,`salary`,`q`.`quoted`,`i`.`invoiced` + from `employees` + left join `q` on `q`.`emp` = `employees`.`id` + left join `i` on `i`.`emp` = `employees`.`id` + +Limiting result-set +------------------- + +.. php:method:: limit($cnt, $shift) + + Limit how many rows will be returned. + + :param int $cnt: number of rows to return + :param int $shift: offset, how many rows to skip + :returns: $this + +Use this to limit your :php:class:`Query` result-set:: + + $q->limit(5, 10); + // .. LIMIT 10, 5 + + $q->limit(5); + // .. LIMIT 0, 5 + +Ordering result-set +------------------- + +.. php:method:: order($order, $desc) + + Orders query result-set in ascending or descending order by single or + multiple fields. + + :param int $order: one or more field names, expression etc. + :param int $desc: pass true to sort descending + :returns: $this + +Use this to order your :php:class:`Query` result-set:: + + $q->order('name'); // .. order by name + $q->order('name desc'); // .. order by name desc + $q->order('name desc, id asc') // .. order by name desc, id asc + $q->order('name',true); // .. order by name desc + +Method can be executed several times on the same Query object. + +Insert and Replace query +============ + +Set value to a field +-------------------- + +.. php:method:: set($field, $value) + + Assigns value to the field during insert. + + :param string $field: name of the field + :param mixed $value: value or expression + :returns: $this + +Example:: + + $q->table('user')->set('name', 'john')->insert(); + // insert into user (name) values (john) + + $q->table('log')->set('date', $q->expr('now()'))->insert(); + // insert into log (date) values (now()) + +Method can be executed several times on the same Query object. + +Set Insert Options +------------------ + +.. php:method:: option($option, $mode = 'select') + +It is possible to add arbitrary options for the query. For example this will fetch unique user birthdays:: + + $q->table('user'); + $q->option('distinct'); + $q->field('birthday'); + $birthdays = $q->get(); + +Other posibility is to set options for delete or insert:: + + $q->option('delayed', 'insert'); + + // or + + $q->option('ignore', 'insert'); + +See your SQL capabilities for additional options (low_priority, delayed, high_priority, ignore) + +Update Query +============ + +Set Conditions +-------------- + +Same syntax as for Select Query. + +Set value to a field +-------------------- + +Same syntax as for Insert Query. + +Other settings +-------------- + +Limit and Order are normally not included to avoid side-effects, but you can +modify :php:attr:`$template_update` to include those tags. + + +Delete Query +============ + +Set Conditions +-------------- + +Same syntax as for Select Query. + + +Other settings +-------------- + +Limit and Order are normally not included to avoid side-effects, but you can +modify :php:attr:`$template_update` to include those tags. + + +Dropping attributes +=================== + +If you have called where() several times, there is a way to remove all the +where clauses from the query and start from beginning: + +.. php:method:: reset($tag) + + :param string $tag: part of the query to delete/reset. + +Example:: + + $q + ->table('user') + ->where('name', 'John'); + ->reset('where') + ->where('name', 'Peter'); + + // where name = 'Peter' + + +Other Methods +============== + + +.. php:method:: dsql($properties) + + Use this instead of `new Query()` if you want to automatically bind query + to the same connection as the parent. + +.. php:method:: expr($template, $args) + + Method very similar to :php:method:`Connection::expr` but will return a + corresponding Expression class for this query. + +.. php:method:: exprNow($precision) + + Method will return current_timestamp(precision) sub-query. + +.. php:method:: option($option, $mode) + + Use this to set additional options for particular query mode. + For example:: + + $q + ->table('test') + ->field('name') + ->set('name', 'John') + ->option('calc_found_rows') // for default select mode + ->option('ignore', 'insert') // for insert mode + ; + + $q->select(); // select calc_found_rows `name` from `test` + $q->insert(); // insert ignore into `test` (`name`) values (`name` = 'John') + +.. php:method:: _set_args($what, $alias, $value) + + Internal method which sets value in :php:attr:`Expression::args` array. + It doesn't allow duplicate aliases and throws Exception in such case. + Argument $what can be 'table' or 'field'. + +.. php:method:: caseExpr($operand) + + Returns new Query object with CASE template. + You can pass operand as parameter to create SQL like + CASE WHEN THEN END type of SQL statement. + +.. php:method:: when($when, $then) + + Set WHEN condition and THEN expression for CASE statement. + +.. php:method:: otherwise($else) + + Set ELSE expression for CASE statement. + + Few examples: + + .. code-block:: php + $s = $this->q()->caseExpr() + ->when(['status','New'], 't2.expose_new') + ->when(['status', 'like', '%Used%'], 't2.expose_used') + ->otherwise(null); + + .. code-block:: sql + case when "status" = 'New' then "t2"."expose_new" when "status" like '%Used%' then "t2"."expose_used" else null end + + .. code-block:: php + $s = $this->q()->caseExpr('status') + ->when('New', 't2.expose_new') + ->when('Used', 't2.expose_used') + ->otherwise(null); + + .. code-block:: sql + case "status" when 'New' then "t2"."expose_new" when 'Used' then "t2"."expose_used" else null end + + +Properties +========== + +.. php:attr:: mode + + Query will use one of the predefined "templates". The mode will contain + name of template used. Basically it's array key of $templates property. + See :ref:`Query Modes`. + +.. php:attr:: defaultField + + If no fields are defined, this field is used. + +.. php:attr:: template_select + + Template for SELECT query. See :ref:`Query Modes`. + +.. php:attr:: template_insert + + Template for INSERT query. See :ref:`Query Modes`. + +.. php:attr:: template_replace + + Template for REPLACE query. See :ref:`Query Modes`. + +.. php:attr:: template_update + + Template for UPDATE query. See :ref:`Query Modes`. + +.. php:attr:: template_delete + + Template for DELETE query. See :ref:`Query Modes`. + +.. php:attr:: template_truncate + + Template for TRUNCATE query. See :ref:`Query Modes`. diff --git a/docs/db/quickstart.rst b/docs/db/quickstart.rst new file mode 100644 index 00000000..e6d052bd --- /dev/null +++ b/docs/db/quickstart.rst @@ -0,0 +1,228 @@ +.. _quickstart: + +========== +Quickstart +========== + +When working with Pluf DB you need to understand the following basic concepts: + + +Basic Concepts +============== + +Expression (see :ref:`expr`) + :php:class:`Expression` object, represents a part of a SQL query. It can + be used to express advanced logic in some part of a query, which + :php:class:`Query` itself might not support or can express a full statement + Never try to look for "raw" queries, instead build expressions and think + about escaping. + +Query (see :ref:`query`) + Object of a :php:class:`Query` class can be used for building and executing + valid SQL statements such as SELECT, INSERT, UPDATE, etc. After creating + :php:class:`Query` object you can call various methods to add "table", + "where", "from" parts of your query. + +Connection (see :ref:`connection`) + Represents a connection to the database. If you already have a PDO object + you can feed it into :php:class:`Expression` or :php:class:`Query`, but + for your comfort there is a :php:class:`Connection` class with very little + overhead. + +Getting Started +=============== + +We will start by looking at the :php:class:`Query` building, because you do +not need a database to create a query:: + + use Pluf\Db\Query; + + $query = new Query(['connection' => $pdo]); + +Once you have a query object, you can add parameters by calling some of it's +methods:: + + $query + ->table('employees') + ->where('birth_date', '1961-05-02') + ->field('count(*)') + ; + +Finally you can get the data:: + + $employee = $query->getOne(); + +While Pluf DB is simple to use for basic queries, it also gives a huge power and +consistency when you are building complex queries. Unlike other query builders +that sometimes rely on "hacks" (such as method whereOr()) and claim to be useful +for "most" database operations, with Pluf, you can use Pluf DB to build ALL of your +database queries. + +This is hugely beneficial for frameworks and large applications, where +various classes need to interact and inject more clauses/fields/joins into your +SQL query. + +Pluf DB does not resolve conflicts between similarly named tables, but it gives you +all the options to use aliases for building a complex query. + +The next example might be a bit too complex for you, but still read through and +try to understand what each section does to your base query:: + + // Establish a query looking for a maximum salary + $salary = new Query(['connection'=>$pdo]); + + // Create few expression objects + $e_ms = $salary->expr('max(salary)'); + $e_df = $salary->expr('TimeStampDiff(month, from_date, to_date)'); + + // Configure our basic query + $salary + ->table('salary') + ->field(['emp_no', 'max_salary'=>$e_ms, 'months'=>$e_df]) + ->group('emp_no') + ->order('-max_salary') + + // Define sub-query for employee "id" with certain birth-date + $employees = $salary->subQuery() + ->table('employees') + ->where('birth_date', '1961-05-02') + ->field('emp_no') + ; + + // Use sub-select to condition salaries + $salary->where('emp_no', $employees); + + // Join with another table for more data + $salary + ->join('employees.emp_id', 'emp_id') + ->field('employees.first_name'); + + + // Finally, fetch result + foreach ($salary as $row) { + echo "Data: ".json_encode($row)."\n"; + } + +The above query resulting code will look like this: + +.. code-block:: sql + + SELECT + `emp_no`, + max(salary) `max_salary`, + TimeStampDiff(month, from_date, to_date) `months` + FROM + `salary` + JOIN + `employees` on `employees`.`emp_id` = `salary`.`emp_id` + WHERE + `salary`.`emp_no` in (select `id` from `employees` where `birth_date` = :a) + GROUP BY `emp_no` + ORDER BY max_salary desc + + :a = "1961-05-02" + +Using Pluf DB in higher level ORM libraries and frameworks allows them to focus on +defining the database logic, while Pluf DB can perform the heavy-lifting of query +building and execution. + +Creating Objects and PDO +======================== +DSQL classes does not need database connection for most of it's work. Once you +create new instance of :ref:`Expression ` or :ref:`Query ` you can +perform operation and finally call :php:meth:`Expression::render()` to get the +final query string:: + + use Pluf\Db\Query; + + $q = (new Query())->table('user')->where('id', 1)->field('name'); + $query = $q->render(); + $params = $q->params; + +When used in application you would typically generate queries with the +purpose of executing them, which makes it very useful to create a +:php:class:`Connection` object. The usage changes slightly:: + + $c = Pluf\Db\Connection::connect($dsn, $user, $password); + $q = $c->query()->table('user')->where('id', 1)->field('name'); + + $name = $q->getOne(); + +You no longer need "use" statement and :php:class:`Connection` class will +automatically do some of the hard work to adopt query building for your +database vendor. +There are more ways to create connection, see `Advanced Connections`_ section. + +The format of the ``$dsn`` is the same as with +`PDO class `_. +If you need to execute query that is not supported by Pluf DB, you should always +use expressions:: + + $tables = $c -> expr('show tables like []', [$like_str])->get(); + +Pluf DB classes are mindful about your SQL vendor and it's quirks, so when you're +building sub-queries with :php:meth:`Query::dsql`, you can avoid some nasty +problems:: + + $sqlite_c ->query()->table('user')->truncate(); + +The above code will work even though SQLite does not support truncate. That's +because Pluf DB takes care of this. + + +Query Building +============== + +Each Query object represents a query to the database in-the-making. +Calling methods such as :php:meth:`Query::table` or :php:meth:`Query::where` +affect part of the query you're making. At any time you can either execute your +query or use it inside another query. + +:php:class:`Query` supports majority of SQL syntax out of the box. +Some unusual statements can be easily added by customizing template for specific +query and we will look into examples in :ref:`extending_query` + +Query Mode +========== + +When you create a new :php:class:`Query` object, it is going to be a *SELECT* +query by default. If you wish to execute ``update`` operation instead, you +simply call :php:meth:`Query::update`, for delete - :php:meth:`Query::delete` +(etc). For more information see :ref:`query-modes`. +You can actually perform multiple operations:: + + $q = $c->query()->table('employee')->where('emp_no', 1234); + $backup_data = $q->get(); + $q->delete(); + +A good practice is to re-use the same query object before you branch out and +perform the action:: + + $q = $c->query()->table('employee')->where('emp_no', 1234); + + if ($confirmed) { + $q->delete(); + } else { + echo "Are you sure you want to delete ".$q->field('count(*)')." employees?"; + } + + +.. _fething-result: + +Fetching Result +=============== + +When you are selecting data from your database, Pluf DB will prepare and execute +statement for you. Depending on the connection, there may be some magic +involved, but once the query is executed, you can start streaming your data:: + + foreach ($query->table('employee')->where('dep_no',123) as $employee) { + echo $employee['first_name']."\n"; + } + +In most cases, when iterating you'll have PDOStatement, however this may not +always be the case, so be cautious. Remember that Pluf DB can support vendors +that PDO does not support as well or can use :ref:`proxy`. +In that case you may end up with other Generator/Iterator but regardless, +`$employee` will always contain associative array representing one row of data. +(See also `Manual Query Execution`_). diff --git a/docs/db/results.rst b/docs/db/results.rst new file mode 100644 index 00000000..91e93501 --- /dev/null +++ b/docs/db/results.rst @@ -0,0 +1,21 @@ +======= +Results +======= + +When query is executed by :php:class:`Connection` or +`PDO `_, it will return an object that +can stream results back to you. The PDO class execution produces a +`PDOStatement `_ object which +you can iterate over. + +If you are using a custom connection, you then will also need a custom object +for streaming results. + +The only requirement for such an object is that it has to be a +`Generator `_. +In most cases developers will expect your generator to return sequence +of id=>hash representing a key/value result set. + + +.. todo:: +write more diff --git a/docs/db/transactions.rst b/docs/db/transactions.rst new file mode 100644 index 00000000..cc72d1c7 --- /dev/null +++ b/docs/db/transactions.rst @@ -0,0 +1,57 @@ +============ +Transactions +============ + +When you work with the DSQL, you can work with transactions. There are 2 +enhancements to the standard functionality of transactions in DSQL: + +1. You can start nested transactions. + +2. You can use :php:meth:`Connection::atomic()` which has a nicer syntax. + +It is recommended to always use atomic() in your code. + +.. php:class:: Connection + + +.. php:method:: atomic($callback) + + Execute callback within the SQL transaction. If callback encounters an + exception, whole transaction will be automatically rolled back:: + + $c->atomic(function() use($c) { + $c->dsql('user')->set('balance=balance+10')->where('id', 10)->update(); + $c->dsql('user')->set('balance=balance-10')->where('id', 14)->update(); + }); + + atomic() can be nested. + The successful completion of a top-most method will commit everything. + Rollback of a top-most method will roll back everything. + +.. php:method:: beginTransaction + + Start new transaction. If already started, will do nothing but will increase + :php:attr:`Connection::$transaction_depth`. + +.. php:method:: commit + + Will commit transaction, however if :php:meth:`Connection::beginTransaction` + was executed more than once, will only decrease + :php:attr:`Connection::$transaction_depth`. + +.. php:method:: inTransaction + + Returns true if transaction is currently active. There is no need for you to + ever use this method. + +.. php:method:: rollBack + + Roll-back the transaction, however if :php:meth:`Connection::beginTransaction` + was executed more than once, will only decrease + :php:attr:`Connection::$transaction_depth`. + + + +.. warning:: If you roll-back internal transaction and commit external + transaction, then result might be unpredictable. + Please discuss this https://github.com/atk4/dsql/issues/89 diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..c3163fb2 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,29 @@ +.. Pluf documentation master file. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to Pluf documentation! +================================ + +Contents: + +.. toctree:: + :maxdepth: 3 + + quickstart + db/index + logger/index + cache/index + db/index + view/index + template/index + model/index + + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/logger/index.rst b/docs/logger/index.rst new file mode 100644 index 00000000..e69de29b diff --git a/docs/logger/logger.rst b/docs/logger/logger.rst new file mode 100644 index 00000000..e69de29b diff --git a/docs/model/index.rst b/docs/model/index.rst new file mode 100644 index 00000000..e69de29b diff --git a/docs/model/repository.rst b/docs/model/repository.rst new file mode 100644 index 00000000..e69de29b diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..449e06ca --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1 @@ +sphinxcontrib-phpdomain==0.1.4 diff --git a/docs/template/embedded-tags.rst b/docs/template/embedded-tags.rst new file mode 100644 index 00000000..e69de29b diff --git a/docs/template/index.rst b/docs/template/index.rst new file mode 100644 index 00000000..e69de29b diff --git a/docs/template/template.rst b/docs/template/template.rst new file mode 100644 index 00000000..e69de29b diff --git a/docs/view/form.rst b/docs/view/form.rst new file mode 100644 index 00000000..e69de29b diff --git a/docs/view/graphql.rst b/docs/view/graphql.rst new file mode 100644 index 00000000..e69de29b diff --git a/docs/view/http.rst b/docs/view/http.rst new file mode 100644 index 00000000..e69de29b diff --git a/docs/view/index.rst b/docs/view/index.rst new file mode 100644 index 00000000..e69de29b diff --git a/docs/view/paginator.rst b/docs/view/paginator.rst new file mode 100644 index 00000000..e69de29b diff --git a/docs/view/view.rst b/docs/view/view.rst new file mode 100644 index 00000000..e69de29b diff --git a/phpunit-mysql-workflow.xml b/phpunit-mysql-workflow.xml new file mode 100644 index 00000000..ee285211 --- /dev/null +++ b/phpunit-mysql-workflow.xml @@ -0,0 +1,24 @@ + + + + + + + + + + tests + + + + + ./vendor + + + ./src + + + + + + diff --git a/phpunit-mysql.xml b/phpunit-mysql.xml new file mode 100644 index 00000000..9a24a7bd --- /dev/null +++ b/phpunit-mysql.xml @@ -0,0 +1,24 @@ + + + + + + + + + + tests + + + + + ./vendor + + + ./src + + + + + + diff --git a/phpunit-pgsql-workflow.xml b/phpunit-pgsql-workflow.xml new file mode 100644 index 00000000..5446c642 --- /dev/null +++ b/phpunit-pgsql-workflow.xml @@ -0,0 +1,24 @@ + + + + + + + + + + tests + + + + + ./vendor + + + ./src + + + + + + diff --git a/phpunit-pgsql.xml b/phpunit-pgsql.xml new file mode 100644 index 00000000..216b7504 --- /dev/null +++ b/phpunit-pgsql.xml @@ -0,0 +1,27 @@ + + + + + + + + + + tests + + + + + ./vendor + + + ./src + + + + + + diff --git a/src6/Data/Encoder/JsonEncoder.php b/src6/Data/Encoder/JsonEncoder.php new file mode 100644 index 00000000..fbed509c --- /dev/null +++ b/src6/Data/Encoder/JsonEncoder.php @@ -0,0 +1,15 @@ +. + */ +namespace Pluf\Data; + +use JsonSerializable; +use Pluf; + +class Model implements JsonSerializable +{ + + + function __construct($pk = null, $values = array()){} + + + //----------------------------------------------------------- + // Old API + //----------------------------------------------------------- + public function init(): void {} + public function getRelationKeysToModel($model, $type): array {} + public function getData(): array {} + public function setAssoc(Model $model, ?string $assocName = null) {} + public function delAssoc(Model $model, ?string $assocName = null) {} + public function batchAssoc($model_name, $ids) {} + public function getOne($p = array()): ?Model {} + public function getList($p = array()) : array{} + public function getCount($p = array()) {} + public function getRelated($model, $method = null, $p = array()) {} + public function update($where = '') {} + public function create($raw = false) {} + public function delete() {} + public function setFromFormData($cleaned_values) {} + public function isAnonymous() {} + public function getSchema() {} + + public function getView(string $name): array {} + public function setView(string $name, array $view): void {} + public function hasView(?string $name = null): bool {} + public function getIndexes(): array {} + + + + /** + * Traditional JSON Encoding + * + * In Pluf V5 supports JsonSerializable for each model, This is a new implementation + * to support old Data Model. + * + * @see JsonSerializable::jsonSerialize() + */ + public function jsonSerialize() + { + return ModelEncoder::getInstance(ModelEncoder::JSON)// + ->setProperties(Pluf::getConfigurationPrifix('data_', true)) + ->setModel($this) + ->encode($this); + } +} + diff --git a/src6/Data/ModelDescription.php b/src6/Data/ModelDescription.php new file mode 100644 index 00000000..6644e982 --- /dev/null +++ b/src6/Data/ModelDescription.php @@ -0,0 +1,32 @@ + $properties + ]); + } + + $this->setDefaults($properties); + } + + /** + * Normalize DSN connection string. + * + * Returns normalized DSN as array ['dsn', 'user', 'pass', 'driver', 'rest']. + * + * @param array|string $dsn + * DSN string + * @param string $user + * Optional username, this takes precedence over dsn string + * @param string $pass + * Optional password, this takes precedence over dsn string + * + * @return array + */ + public static function normalizeDSN($dsn, $user = null, $pass = null) + { + // Try to dissect DSN into parts + $parts = is_array($dsn) ? $dsn : parse_url($dsn); + + // If parts are usable, convert DSN format + if ($parts !== false && isset($parts['host'], $parts['path'])) { + // DSN is using URL-like format, so we need to convert it + $dsn = $parts['scheme'] . ':host=' . $parts['host'] . (isset($parts['port']) ? ';port=' . $parts['port'] : '') . ';dbname=' . substr($parts['path'], 1); + $user = $user !== null ? $user : (isset($parts['user']) ? $parts['user'] : null); + $pass = $pass !== null ? $pass : (isset($parts['pass']) ? $parts['pass'] : null); + } + + // If it's still array, then simply use it + if (is_array($dsn)) { + return $dsn; + } + + // If it's string, then find driver + if (is_string($dsn)) { + if (strpos($dsn, ':') === false) { + throw new Exception([ + "Your DSN format is invalid. Must be in 'driver:host;options' format", + 'dsn' => $dsn + ]); + } + list ($driver, $rest) = explode(':', $dsn, 2); + $driver = strtolower($driver); + } else { + // currently impossible to be like this, but we don't want ugly exceptions here + $driver = $rest = null; + } + + return [ + 'dsn' => $dsn, + 'user' => $user, + 'pass' => $pass, + 'driver' => $driver, + 'rest' => $rest + ]; + } + + /** + * Connect database. + * + * @param string|\PDO $dsn + * @param null|string $user + * @param null|string $password + * @param array $args + * + * @return Connection + */ + public static function connect($dsn, $user = null, $password = null, $args = []): Connection + { + // If it's already PDO object, then we simply use it + if ($dsn instanceof \PDO) { + $driver = $dsn->getAttribute(\PDO::ATTR_DRIVER_NAME); + $connectionClass = self::class; + $queryClass = null; + $expressionClass = null; + switch ($driver) { + case 'pgsql': + $connectionClass = Connection\PgSQL::class; + $queryClass = Query\PgSQL::class; + break; + case 'oci': + $connectionClass = Connection\Oracle::class; + break; + case 'sqlite': + $queryClass = Query\SQLite::class; + break; + case 'mysql': + $expressionClass = Expression\MySQL::class; + default: + // Default, for backwards compatibility + $queryClass = Query\MySQL::class; + break; + } + + return new $connectionClass(array_merge([ + 'connection' => $dsn, + 'query_class' => $queryClass, + 'expression_class' => $expressionClass, + 'driver' => $driver + ], $args)); + } + + // If it's some other object, then we simply use it trough proxy connection + if (is_object($dsn)) { + return new Connection\Proxy(array_merge([ + 'connection' => $dsn + ], $args)); + } + + // Process DSN string + $dsn = static::normalizeDSN($dsn, $user, $password); + + // Create driver specific connection + switch ($dsn['driver']) { + case 'mysql': + $c = new static(array_merge([ + 'connection' => new \PDO($dsn['dsn'], $dsn['user'], $dsn['pass']), + 'expression_class' => Expression\MySQL::class, + 'query_class' => Query\MySQL::class, + 'driver' => $dsn['driver'] + ], $args)); + break; + + case 'sqlite': + $c = new static(array_merge([ + 'connection' => new \PDO($dsn['dsn'], $dsn['user'], $dsn['pass']), + 'query_class' => Query\SQLite::class, + 'driver' => $dsn['driver'] + ], $args)); + break; + + case 'oci': + $c = new Connection\Oracle(array_merge([ + 'connection' => new \PDO($dsn['dsn'], $dsn['user'], $dsn['pass']), + 'driver' => $dsn['driver'] + ], $args)); + break; + + case 'oci12': + $dsn['dsn'] = str_replace('oci12:', 'oci:', $dsn['dsn']); + $c = new Connection\Oracle12(array_merge([ + 'connection' => new \PDO($dsn['dsn'], $dsn['user'], $dsn['pass']), + 'driver' => $dsn['driver'] + ], $args)); + break; + + case 'pgsql': + $c = new Connection\PgSQL(array_merge([ + 'connection' => new \PDO($dsn['dsn'], $dsn['user'], $dsn['pass']), + 'driver' => $dsn['driver'] + ], $args)); + break; + + case 'dumper': + $c = new Connection\Dumper(array_merge([ + 'connection' => static::connect($dsn['rest'], $dsn['user'], $dsn['pass']) + ], $args)); + break; + + case 'counter': + $c = new Connection\Counter(array_merge([ + 'connection' => static::connect($dsn['rest'], $dsn['user'], $dsn['pass']) + ], $args)); + break; + + // let PDO handle the rest + default: + $c = new static(array_merge([ + 'connection' => static::connect(new \PDO($dsn['dsn'], $dsn['user'], $dsn['pass'])) + ], $args)); + } + + return $c; + } + + /** + * Returns new Query object with connection already set. + * + * @param array $properties + * + * @return Query + */ + public function query($properties = []): Query + { + $c = $this->query_class; + $q = new $c($properties); + $q->connection = $this; + + return $q; + } + + /** + * + * @deprecated use query + * @param array $properties + * @return \Pluf\Db\Query + */ + public function dsql($properties = []) + { + return $this->query($properties); + } + + /** + * Returns Expression object with connection already set. + * + * @param array $properties + * @param array $arguments + * + * @return Expression + */ + public function expr($properties = [], $arguments = null) + { + $c = $this->expression_class; + $e = new $c($properties, $arguments); + $e->connection = $this->connection ?: $this; + + return $e; + } + + /** + * Returns Connection or PDO object. + * + * @return Connection|\PDO + */ + public function connection() + { + return $this->connection; + } + + /** + * Execute Expression by using this connection. + * + * @param Expression $expr + * + * @return \PDOStatement + */ + public function execute(Expression $expr) + { + // If custom connection is set, execute again using that + if ($this->connection && $this->connection !== $this) { + return $expr->execute($this->connection); + } + + throw new Exception('Queries cannot be executed through this connection'); + } + + /** + * Atomic executes operations within one begin/end transaction, so if + * the code inside callback will fail, then all of the transaction + * will be also rolled back. + */ + public function atomic($f) + { + $this->beginTransaction(); + + try { + $res = call_user_func($f); + $this->commit(); + + return $res; + } catch (\Exception $e) { + $this->rollBack(); + + throw $e; + } + } + + /** + * Starts new transaction. + * + * Database driver supports statements for starting and committing + * transactions. Unfortunately most of them don't allow to nest + * transactions and commit gradually. + * With this method you have some implementation of nested transactions. + * + * When you call it for the first time it will begin transaction. If you + * call it more times, it will do nothing but will increase depth counter. + * You will need to call commit() for each execution of beginTransactions() + * and only the last commit will perform actual commit in database. + * + * So, if you have been working with the database and got un-handled + * exception in the middle of your code, everything will be rolled back. + * + * @return mixed Don't rely on any meaningful return + */ + public function beginTransaction() + { + // transaction starts only if it was not started before + $r = $this->inTransaction() ? false : $this->connection->beginTransaction(); + + $this->transaction_depth ++; + + return $r; + } + + /** + * Will return true if currently running inside a transaction. + * This is useful if you are logging anything into a database. If you are + * inside a transaction, don't log or it may be rolled back. + * Perhaps use a hook for this? + * + * @see beginTransaction() + * + * @return bool if in transaction + */ + public function inTransaction() + { + return $this->transaction_depth > 0; + } + + /** + * Commits transaction. + * + * Each occurrence of beginTransaction() must be matched with commit(). + * Only when same amount of commits are executed, the actual commit will be + * issued to the database. + * + * @see beginTransaction() + * + * @return mixed Don't rely on any meaningful return + */ + public function commit() + { + // check if transaction is actually started + if (! $this->inTransaction()) { + throw new Exception('Using commit() when no transaction has started'); + } + + $this->transaction_depth --; + + if ($this->transaction_depth == 0) { + return $this->connection->commit(); + } + + return false; + } + + /** + * Rollbacks queries since beginTransaction and resets transaction depth. + * + * @see beginTransaction() + * + * @return mixed Don't rely on any meaningful return + */ + public function rollBack() + { + // check if transaction is actually started + if (! $this->inTransaction()) { + throw new Exception('Using rollBack() when no transaction has started'); + } + + $this->transaction_depth --; + + if ($this->transaction_depth == 0) { + return $this->connection->rollBack(); + } + + return false; + } + + /** + * Return last inserted ID value. + * + * Few Connection drivers need to receive Model to get ID because PDO doesn't support this method. + * + * @param + * \atk4\data\Model Optional data model from which to return last ID + * + * @return mixed + */ + public function lastInsertID($m = null) + { + return $this->connection()->lastInsertID(); + } +} diff --git a/src6/Db/Connection/Counter.php b/src6/Db/Connection/Counter.php new file mode 100644 index 00000000..4651dd76 --- /dev/null +++ b/src6/Db/Connection/Counter.php @@ -0,0 +1,101 @@ + $row) { + $this->rows ++; + yield $key => $row; + } + } + + /** + * Execute expression. + * + * @param Expression $expr + * + * @return mixed + */ + public function execute(Expression $expr) + { + if ($expr instanceof Query) { + $this->queries ++; + if ($expr->mode === 'select' || $expr->mode === null) { + $this->selects ++; + } + } else { + $this->expressions ++; + } + + try { + $ret = parent::execute($expr); + } catch (\Exception $e) { + if ($this->callback && is_callable($this->callback)) { + call_user_func($this->callback, $this->queries, $this->selects, $this->rows, $this->expressions, true); + } else { + printf("[ERROR] Queries: %3d, Selects: %3d, Rows fetched: %4d, Expressions %3d\n", $this->queries, $this->selects, $this->rows, $this->expressions); + } + + throw $e; + } + + return $this->iterate($ret); + } + + /** + * Log results when destructing. + */ + public function __destruct() + { + if ($this->callback && is_callable($this->callback)) { + call_user_func($this->callback, $this->queries, $this->selects, $this->rows, $this->expressions, false); + } else { + printf("Queries: %3d, Selects: %3d, Rows fetched: %4d, Expressions %3d\n", $this->queries, $this->selects, $this->rows, $this->expressions); + } + } +} diff --git a/src6/Db/Connection/Dumper.php b/src6/Db/Connection/Dumper.php new file mode 100644 index 00000000..92a4aa9a --- /dev/null +++ b/src6/Db/Connection/Dumper.php @@ -0,0 +1,67 @@ +start_time = microtime(true); + + try { + $ret = parent::execute($expr); + $took = microtime(true) - $this->start_time; + + if ($this->callback && is_callable($this->callback)) { + call_user_func($this->callback, $expr, $took, false); + } else { + printf("[%02.6f] %s\n", $took, $expr->getDebugQuery()); + } + } catch (\Exception $e) { + $took = microtime(true) - $this->start_time; + + if ($this->callback && is_callable($this->callback)) { + call_user_func($this->callback, $expr, $took, true); + } else { + printf("[ERROR %02.6f] %s\n", $took, $expr->getDebugQuery()); + } + + throw $e; + } + + return $ret; + } +} diff --git a/src6/Db/Connection/Oracle.php b/src6/Db/Connection/Oracle.php new file mode 100644 index 00000000..9b3e06cb --- /dev/null +++ b/src6/Db/Connection/Oracle.php @@ -0,0 +1,68 @@ +expr('ALTER SESSION SET NLS_TIMESTAMP_FORMAT={datetime_format} NLS_DATE_FORMAT={date_format} NLS_NUMERIC_CHARACTERS={dec_char}', [ + 'datetime_format' => 'YYYY-MM-DD HH24:MI:SS', // datetime format + 'date_format' => 'YYYY-MM-DD', // date format + 'dec_char' => '. ' // decimal separator, no thousands separator + ])->execute(); + } + + /** + * Return last inserted ID value. + * + * Few Connection drivers need to receive Model to get ID because PDO doesn't support this method. + * + * @param + * \atk4\data\Model Optional data model from which to return last ID + * + * @return mixed + */ + public function lastInsertID($m = null) + { + if ($m instanceof Pluf_Model) { + // if we use sequence, then we can easily get current value + if (isset($m->sequence)) { + return $this->dsql() + ->mode('seq_currval') + ->sequence($m->sequence) + ->getOne(); + } + + // otherwise we have to select max(id_field) - this can be bad for performance !!! + // Imants: Disabled for now because otherwise this will work even if database use triggers or + // any other mechanism to automatically increment ID and we can't tell this line to not execute. + // return $this->expr('SELECT max([field]) FROM [table]', ['field'=>$m->id_field, 'table'=>$m->table])->getOne(); + } + + // fallback + return parent::lastInsertID($m); + } +} diff --git a/src6/Db/Connection/Oracle12.php b/src6/Db/Connection/Oracle12.php new file mode 100644 index 00000000..ff1221e7 --- /dev/null +++ b/src6/Db/Connection/Oracle12.php @@ -0,0 +1,18 @@ +connection()->lastInsertID($m->sequence ?: $m->table . '_' . $m->id_field . '_seq'); + } +} diff --git a/src6/Db/Connection/Proxy.php b/src6/Db/Connection/Proxy.php new file mode 100644 index 00000000..894c678b --- /dev/null +++ b/src6/Db/Connection/Proxy.php @@ -0,0 +1,55 @@ +connection instanceof \Pluf\Db\Connection && $this->connection->driver) { + $this->driver = $this->connection->driver; + } + } + + public function connection() + { + return $this->connection->connection(); + } + + public function dsql($properties = []) + { + $dsql = $this->connection->dsql($properties); + $dsql->connection = $this; + + return $dsql; + } + + public function expr($properties = [], $arguments = null) + { + $expr = $this->connection->expr($properties, $arguments); + $expr->connection = $this; + + return $expr; + } + + public function execute(Expression $expr) + { + return $this->connection->execute($expr); + } +} diff --git a/src6/Db/Exception.php b/src6/Db/Exception.php new file mode 100644 index 00000000..f444624e --- /dev/null +++ b/src6/Db/Exception.php @@ -0,0 +1,12 @@ + [] + ]; + + /** + * As per PDO, _param() will convert value into :a, :b, :c .. + * :aa .. etc. + * + * @var string + */ + protected $paramBase = 'a'; + + /** + * Field, table and alias name escaping symbol. + * By SQL Standard it's double quote, but MySQL uses backtick. + * + * @var string + */ + protected $escape_char = '"'; + + /** + * Used for Linking. + * + * @var string + */ + public $_paramBase = null; + + /** + * Will be populated with actual values by _param(). + * + * @var array + */ + public $params = []; + + /** + * When you are willing to execute the query, connection needs to be specified. + * By default this is PDO object. + * + * @var \PDO|Connection + */ + public $connection = null; + + /** + * Holds references to bound parameter values. + * + * This is needed to use bindParam instead of bindValue and to be able to use 4th parameter of bindParam. + * + * @var array + */ + private $boundValues = []; + + /** + * Specifying options to constructors will override default + * attribute values of this class. + * + * If $properties is passed as string, then it's treated as template. + * + * @param string|array $properties + * @param array $arguments + */ + public function __construct($properties = [], $arguments = null) + { + // save template + if (is_string($properties)) { + $properties = [ + 'template' => $properties + ]; + } elseif (! is_array($properties)) { + throw new Exception([ + 'Incorrect use of Expression constructor', + 'properties' => $properties, + 'arguments' => $arguments + ]); + } + + // supports passing template as property value without key 'template' + if (isset($properties[0])) { + $properties['template'] = $properties[0]; + unset($properties[0]); + } + + // save arguments + if ($arguments !== null) { + if (! is_array($arguments)) { + throw new Exception([ + 'Expression arguments must be an array', + 'properties' => $properties, + 'arguments' => $arguments + ]); + } + $this->args['custom'] = $arguments; + } + + // deal with remaining properties + foreach ($properties as $key => $val) { + $this->$key = $val; + } + } + + /** + * Casting to string will execute expression and return getOne() value. + * + * @return string + */ + public function __toString() + { + return (string) $this->getOne(); + } + + /** + * Assigns a value to the specified offset. + * + * @param + * string The offset to assign the value to + * @param + * mixed The value to set + * @abstracting ArrayAccess + */ + public function offsetSet($offset, $value) + { + if ($offset === null) { + $this->args['custom'][] = $value; + } else { + $this->args['custom'][$offset] = $value; + } + } + + /** + * Whether or not an offset exists. + * + * @param + * string An offset to check for + * + * @return bool + * @abstracting ArrayAccess + */ + public function offsetExists($offset) + { + return array_key_exists($offset, $this->args['custom']); + } + + /** + * Unsets an offset. + * + * @param + * string The offset to unset + * @abstracting ArrayAccess + */ + public function offsetUnset($offset) + { + unset($this->args['custom'][$offset]); + } + + /** + * Returns the value at specified offset. + * + * @param + * string The offset to retrieve + * + * @return mixed + * @abstracting ArrayAccess + */ + public function offsetGet($offset) + { + return $this->args['custom'][$offset]; + } + + /** + * Use this instead of "new Expression()" if you want to automatically bind + * new expression to the same connection as the parent. + * + * @param array|string $properties + * @param array $arguments + * + * @return Expression + */ + public function expr($properties = [], $arguments = null) + { + // If we use DSQL Connection, then we should call expr() from there. + // Connection->expr() will return correct, connection specific Expression class. + if ($this->connection instanceof Connection) { + return $this->connection->expr($properties, $arguments); + } + + // Otherwise, connection is probably PDO and we don't know which Expression + // class to use, so we make a smart guess :) + if ($this instanceof Query) { + $e = new self($properties, $arguments); + } else { + $e = new static($properties, $arguments); + } + + $e->escape_char = $this->escape_char; + $e->connection = $this->connection; + + return $e; + } + + /** + * Resets arguments. + * + * @param string $tag + * + * @return $this + */ + public function reset($tag = null) + { + // unset all arguments + if ($tag === null) { + $this->args = [ + 'custom' => [] + ]; + + return $this; + } + + if (! is_string($tag)) { + throw new Exception([ + 'Tag should be string', + 'tag' => $tag + ]); + } + + // unset custom/argument or argument if such exists + if ($this->offsetExists($tag)) { + $this->offsetUnset($tag); + } elseif (isset($this->args[$tag])) { + unset($this->args[$tag]); + } + + return $this; + } + + /** + * Recursively renders sub-query or expression, combining parameters. + * + * @param mixed $sql_code + * Expression + * @param string $escape_mode + * Fall-back escaping mode - param|escape|none + * + * @return string|array Quoted expression or array of param names + */ + protected function _consume($sql_code, $escape_mode = 'param') + { + if (! is_object($sql_code)) { + switch ($escape_mode) { + case 'param': + return $this->_param($sql_code); + case 'escape': + return $this->_escape($sql_code); + case 'soft-escape': + return $this->_escapeSoft($sql_code); + case 'none': + return $sql_code; + } + + throw new Exception([ + '$escape_mode value is incorrect', + 'escape_mode' => $escape_mode + ]); + } + + // User may add Expressionable trait to any class, then pass it's objects + if ($sql_code instanceof Expressionable) { + $sql_code = $sql_code->getDSQLExpression($this); + } + + if (! $sql_code instanceof self) { + throw new Exception([ + 'Only Expressions or Expressionable objects may be used in Expression', + 'object' => $sql_code + ]); + } + + // at this point $sql_code is instance of Expression + $sql_code->params = &$this->params; + $sql_code->_paramBase = &$this->_paramBase; + $ret = $sql_code->render(); + + // Queries should be wrapped in parentheses in most cases + if ($sql_code instanceof Query) { + $ret = '(' . $ret . ')'; + } + + // unset is needed here because ->params=&$othervar->params=foo will also change $othervar. + // if we unset() first, we’re safe. + unset($sql_code->params); + $sql_code->params = []; + + return $ret; + } + + /** + * Given the string parameter, it will detect some "deal-breaker" for our + * soft escaping, such as "*" or "(". + * Those will typically indicate that expression is passed and shouldn't + * be escaped. + */ + protected function isUnescapablePattern($value) + { + return is_object($value) || $value === '*' || strpos($value, '(') !== false || strpos($value, $this->escape_char) !== false; + } + + /** + * Soft-escaping SQL identifier. + * This method will attempt to put + * escaping char around the identifier, however will not do so if you + * are using special characters like ".", "(" or escaping char. + * + * It will smartly escape table.field type of strings resulting + * in "table"."field". + * + * @param mixed $value + * Any string or array of strings + * + * @return string|array Escaped string or array of strings + */ + protected function _escapeSoft($value) + { + // supports array + if (is_array($value)) { + return array_map(__METHOD__, $value); + } + + // in some cases we should not escape + if ($this->isUnescapablePattern($value)) { + return $value; + } + + if (is_string($value) && strpos($value, '.') !== false) { + return implode('.', array_map(__METHOD__, explode('.', $value))); + } + + return $this->escape_char . trim($value) . $this->escape_char; + } + + /** + * Creates new expression where $sql_code appears escaped. + * Use this + * method as a conventional means of specifying arguments when you + * think they might have a nasty back-ticks or commas in the field + * names. + * + * @param string $value + * + * @return Expression + */ + public function escape($value) + { + return $this->expr('{}', [ + $value + ]); + } + + /** + * Escapes argument by adding backticks around it. + * This will allow you to use reserved SQL words as table or field + * names such as "table" as well as other characters that SQL + * permits in the identifiers (e.g. spaces or equation signs). + * + * @param mixed $value + * Any string or array of strings + * + * @return string|array Escaped string or array of strings + */ + protected function _escape($value) + { + // supports array + if (is_array($value)) { + return array_map(__METHOD__, $value); + } + + // in all other cases we should escape + $c = $this->escape_char; + + return $c . str_replace($c, $c . $c, $value) . $c; + } + + /** + * Converts value into parameter and returns reference. + * Use only during + * query rendering. Consider using `_consume()` instead, which will + * also handle nested expressions properly. + * + * @param string|array $value + * String literal or array of strings containing input data + * + * @return string|array Name of parameter or array of names + */ + protected function _param($value) + { + // supports array + if (is_array($value)) { + return array_map(__METHOD__, $value); + } + + $name = ':' . $this->_paramBase; + $this->_paramBase ++; + $this->params[$name] = $value; + + return $name; + } + + /** + * Render expression and return it as string. + * + * @return string Rendered query + */ + public function render() + { + $nameless_count = 0; + if (! isset($this->_paramBase)) { + $this->_paramBase = $this->paramBase; + } + + if ($this->template === null) { + throw new Exception('Template is not defined for Expression'); + } + + $res = preg_replace_callback( + // param | escape | escapeSoft + '/\[[a-z0-9_]*\]|{[a-z0-9_]*}|{{[a-z0-9_]*}}/i', function ($matches) use (&$nameless_count) { + $identifier = substr($matches[0], 1, - 1); + + if ($matches[0][0] == '[') { + $escaping = 'param'; + } elseif ($matches[0][0] == '{') { + if ($matches[0][1] == '{') { + $escaping = 'soft-escape'; + $identifier = substr($identifier, 1, - 1); + } else { + $escaping = 'escape'; + } + } + + // Allow template to contain [] + if ($identifier === '') { + $identifier = $nameless_count ++; + + // use rendering only with named tags + } + $fx = '_render_' . $identifier; + + // [foo] will attempt to call $this->_render_foo() + + if (array_key_exists($identifier, $this->args['custom'])) { + $value = $this->_consume($this->args['custom'][$identifier], $escaping); + } elseif (method_exists($this, $fx)) { + $value = $this->$fx(); + } else { + throw new Exception([ + 'Expression could not render tag', + 'tag' => $identifier + ]); + } + + return is_array($value) ? '(' . implode(',', $value) . ')' : $value; + }, $this->template); + unset($this->_paramBase); + + return trim($res); + } + + /** + * Return formatted debug output. + * + * Ignore false positive warnings of PHPMD. + * + * @SuppressWarnings(PHPMD.StaticAccess) + * + * @param bool $html + * Show as HTML? + * + * @return string SQL syntax of query + */ + public function getDebugQuery($html = null) + { + $d = $this->render(); +// $pp = []; + foreach (array_reverse($this->params) as $key => $val) { + if (is_numeric($val)) { + $d = preg_replace('/' . $key . '([^_]|$)/', $val . '\1', $d); + } elseif (is_string($val)) { + $d = preg_replace('/' . $key . '([^_]|$)/', "'" . addslashes($val) . "'\\1", $d); + } elseif ($val === null) { + $d = preg_replace('/' . $key . '([^_]|$)/', 'NULL\1', $d); + } else { + $d = preg_replace('/' . $key . '([^_]|$)/', $val . '\\1', $d); + } +// $pp[] = $key; + } +// if (class_exists('SqlFormatter')) { +// if ($html) { +// $result = \SqlFormatter::format($d); +// } else { +// $result = \SqlFormatter::format($d, false); +// } +// } else { + $result = $d; // output as-is +// } + if (! $html) { + return str_replace('#lte#', '<=', strip_tags(str_replace('<=', '#lte#', $result), '<>')); + } + + return $result; + } + + public function __debugInfo() + { + $arr = [ + 'R' => false, + 'template' => $this->template, + 'params' => $this->params, + // 'connection' => $this->connection, + 'args' => $this->args + ]; + + try { + $arr['R'] = $this->getDebugQuery(); + } catch (\Exception $e) { + $arr['R'] = $e->getMessage(); + } + + return $arr; + } + + /** + * Execute expression. + * + * @param \PDO|Connection $connection + * + * @return \PDOStatement + */ + public function execute($connection = null) + { + if ($connection === null) { + $connection = $this->connection; + } + + // If it's a PDO connection, we're cool + if ($connection instanceof \PDO) { + $connection->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + // We support PDO + $query = $this->render(); + $statement = $connection->prepare($query); + foreach ($this->params as $key => $val) { + if (is_int($val)) { + $type = \PDO::PARAM_INT; + } elseif (is_bool($val)) { + // SQL does not like booleans at all, so convert them INT + $type = \PDO::PARAM_INT; + $val = (int) $val; + } elseif ($val === null) { + $type = \PDO::PARAM_NULL; + } elseif (is_string($val) || is_float($val)) { + $type = \PDO::PARAM_STR; + } elseif (is_resource($val)) { + $type = \PDO::PARAM_LOB; + } else { + throw new Exception([ + 'Incorrect param type', + 'key' => $key, + 'value' => $val, + 'type' => gettype($val) + ]); + } + + // Workaround to support LOB data type. See https://github.com/doctrine/dbal/pull/2434 + $this->boundValues[$key] = $val; + if ($type === \PDO::PARAM_STR) { + $bind = $statement->bindParam($key, $this->boundValues[$key], $type, strlen($val)); + } else { + $bind = $statement->bindParam($key, $this->boundValues[$key], $type); + } + + if (! $bind) { + throw new Exception([ + 'Unable to bind parameter', + 'param' => $key, + 'value' => $val, + 'type' => $type + ]); + } + } + + $statement->setFetchMode(\PDO::FETCH_ASSOC); + + try { + $statement->execute(); + } catch (\Exception $e) { + $new = new Exception([ + 'DSQL got Exception when executing this query', + 'error' => $e->getMessage(), + 'query' => $this->getDebugQuery() + ]); + $new->by_exception = $e; + + throw $new; + } + + return $statement; + } else { + return $connection->execute($this); + } + } + + /** + * Returns ArrayIterator, for example PDOStatement. + * + * @return \PDOStatement + * @abstracting \IteratorAggregate + */ + public function getIterator() + { + return $this->execute(); + } + + // {{{ Result Querying + + /** + * Executes expression and return whole result-set in form of array of hashes. + * + * @return array + */ + public function get() + { + $stmt = $this->execute(); + + if ($stmt instanceof \Generator) { + return iterator_to_array($stmt); + } + + return $stmt->fetchAll(); + } + + /** + * Executes expression and return first value of first row of data from result-set. + * + * @return string + */ + public function getOne() + { + $data = $this->getRow(); + if (! $data) { + throw new Exception([ + 'Unable to fetch single cell of data for getOne from this query', + 'result' => $data, + 'query' => $this->getDebugQuery() + ]); + } + $one = array_shift($data); + + return $one; + } + + /** + * Executes expression and returns first row of data from result-set as a hash. + * + * @return array + */ + public function getRow() + { + $stmt = $this->execute(); + + if ($stmt instanceof \Generator) { + return $stmt->current(); + } + + return $stmt->fetch(); + } + + // }}} +} diff --git a/src6/Db/Expression/MySQL.php b/src6/Db/Expression/MySQL.php new file mode 100644 index 00000000..a57b0f5d --- /dev/null +++ b/src6/Db/Expression/MySQL.php @@ -0,0 +1,17 @@ +type_cast['Boolean'] = $this->type_cast['Boolean'] = array( - '\Pluf\Db\PostgreSQLEngine::booleanFromDb', - '\Pluf\Db\PostgreSQLEngine::booleanToDb' - ); - $this->type_cast['Compressed'] = $this->type_cast['Compressed'] = array( - '\Pluf\Db\PostgreSQLEngine::compressedFromDb', - '\Pluf\Db\PostgreSQLEngine::compressedToDb' - ); - - $cstring = ''; - if ($server) { - $cstring .= 'host=' . $server . ' '; - } - $cstring .= 'dbname=' . $dbname . ' user=' . $user; - if ($pwd) { - $cstring .= ' password=' . $pwd; - } - $this->pfx = $pfx; - $this->cur = null; - $this->con_id = @pg_connect($cstring); - if (! $this->con_id) { - throw new \Pluf\Exception($this->getError()); - } - } - - /** - * Get the version of the PostgreSQL server. - * - * Requires PostgreSQL 7.4 or later. - * - * @return string Version string - */ - function getServerInfo() - { - $ver = pg_version($this->con_id); - return $ver['server']; - } - - function close() - { - if ($this->con_id) { - pg_close($this->con_id); - return true; - } else { - return false; - } - } - - function select($query) - { - $this->cur = @pg_query($this->con_id, $query); - if (! $this->cur) { - throw new \Pluf\Exception($this->getError()); - } - $res = array(); - while ($row = pg_fetch_assoc($this->cur)) { - $res[] = $row; - } - @pg_free_result($this->cur); - $this->cur = null; - return $res; - } - - function execute($query) - { - $this->cur = @pg_query($this->con_id, $query); - if (! $this->cur) { - throw new \Pluf\Exception($this->getError()); - } - return true; - } - - function getLastID() - { - $res = $this->select('SELECT lastval() AS last_id'); - return (int) $res[0]['last_id']; - } - - /** - * Returns a string ready to be used in the exception. - * - * @return string Error string - */ - function getError() - { - if ($this->cur) { - return pg_result_error($this->cur) . ' - ' . $this->lastquery; - } - if ($this->con_id) { - return pg_last_error($this->con_id) . ' - ' . $this->lastquery; - } else { - return pg_last_error() . ' - ' . $this->lastquery; - } - } - - function esc($str) - { - if (is_array($str)) { - $res = array(); - foreach ($str as $s) { - $res[] = '\'' . pg_escape_string($this->con_id, $s) . '\''; - } - return implode(', ', $res); - } - return '\'' . pg_escape_string($this->con_id, $str) . '\''; - } - - /** - * Set the current search path. - */ - function setSearchPath($search_path = 'public') - { - if (preg_match('/[^\w\s\,]/', $search_path)) { - throw new \Pluf\Exception('The search path: "' . $search_path . '" is not valid.'); - } - $this->execute('SET search_path TO ' . $search_path); - $this->search_path = $search_path; - return true; - } - - /** - * Start a transaction. - */ - function begin() - { - $this->execute('BEGIN'); - } - - /** - * Commit a transaction. - */ - function commit() - { - $this->execute('COMMIT'); - } - - /** - * Rollback a transaction. - */ - function rollback() - { - $this->execute('ROLLBACK'); - } - - function __toString() - { - return 'con_id . ')>'; - } - - public static function booleanFromDb($val): bool - { - if (! $val) { - return false; - } - return (strtolower(substr($val, 0, 1)) == 't'); - } - - public static function booleanToDb($val, $db) - { - if (null === $val) { - return 'NULL'; - } - if ($val) { - return '1'; - } - return '0'; - } - - public static function compressedToDb($val, $con) - { - if (is_null($val)) { - return 'NULL'; - } - return "'" . pg_escape_bytea(gzdeflate($val, 9)) . "'"; - } - - public static function compressedFromDb($val) - { - return ($val) ? gzinflate(pg_unescape_bytea($val)) : $val; - } - public function isLive(): bool - {} - -} - diff --git a/src6/Db/PostgreSQLSchema.php b/src6/Db/PostgreSQLSchema.php deleted file mode 100644 index 187df556..00000000 --- a/src6/Db/PostgreSQLSchema.php +++ /dev/null @@ -1,312 +0,0 @@ - 'character varying', - Engine::SEQUENCE => 'serial', - Engine::BOOLEAN => 'boolean', - Engine::DATE => 'date', - Engine::DATETIME => 'timestamp', - Engine::FILE => 'character varying', - Engine::MANY_TO_MANY => null, - Engine::FOREIGNKEY => 'integer', - Engine::TEXT => 'text', - Engine::HTML => 'text', - Engine::TIME => 'time', - Engine::INTEGER => 'integer', - Engine::EMAIL => 'character varying', - Engine::PASSWORD => 'character varying', - Engine::FLOAT => 'real', - Engine::BLOB => 'bytea' - ); - - public $defaults = array( - Engine::VARCHAR => "''", - Engine::SEQUENCE => null, - Engine::BOOLEAN => 'FALSE', - Engine::DATE => "'0001-01-01'", - Engine::DATETIME => "'0001-01-01 00:00:00'", - Engine::FILE => "''", - Engine::MANY_TO_MANY => null, - Engine::FOREIGNKEY => 0, - Engine::TEXT => "''", - Engine::HTMLL => "''", - Engine::TIME => "'00:00:00'", - Engine::DATETIME => 0, - Engine::EMAIL => "''", - Engine::PASSWORD => "''", - Engine::FLOAT => 0.0, - Engine::BLOB => "''" - ); - - private $con = null; - - function __construct($con) - { - $this->con = $con; - } - - /** - * Get the SQL to generate the tables of the given model. - * - * @param - * Object Model - * @return array Array of SQL strings ready to execute. - */ - function getSqlCreate($model) - { - $tables = array(); - $cols = $model->_a['cols']; - $manytomany = array(); - $query = 'CREATE TABLE ' . $this->con->pfx . $model->_a['table'] . ' ('; - $sql_col = array(); - foreach ($cols as $col => $val) { - $field = new $val['type'](); - if ($field->type != 'manytomany') { - $sql = $this->con->qn($col) . ' '; - $sql .= $this->mappings[$field->type]; - if (empty($val['is_null'])) { - $sql .= ' NOT NULL'; - } - if (isset($val['default'])) { - $sql .= ' default '; - $sql .= $model->_toDb($val['default'], $col); - } elseif ($field->type != 'sequence') { - $sql .= ' default ' . $this->defaults[$field->type]; - } - $sql_col[] = $sql; - } else { - $manytomany[] = $col; - } - } - $sql_col[] = 'CONSTRAINT ' . $this->con->pfx . $model->_a['table'] . '_pkey PRIMARY KEY (id)'; - $query = $query . "\n" . implode(",\n", $sql_col) . "\n" . ');'; - $tables[$this->con->pfx . $model->_a['table']] = $query; - // Now for the many to many - // FIXME add index on the second column - foreach ($manytomany as $many) { - $omodel = new $cols[$many]['model'](); - $table = Pluf_ModelUtils::getAssocTable($model, $omodel); - - $ra = Pluf_ModelUtils::getAssocField($model); - $rb = Pluf_ModelUtils::getAssocField($omodel); - - $sql = 'CREATE TABLE ' . $table . ' ('; - $sql .= "\n" . $ra . ' ' . $this->mappings[Engine::FOREIGNKEY] . ' default 0,'; - $sql .= "\n" . $rb . ' ' . $this->mappings[Engine::FOREIGNKEY] . ' default 0,'; - $sql .= "\n" . 'CONSTRAINT ' . $this->getShortenedIdentifierName($this->con->pfx . $table . '_pkey') . ' PRIMARY KEY (' . $ra . ', ' . $rb . ')'; - $sql .= "\n" . ');'; - $tables[$this->con->pfx . $table] = $sql; - } - return $tables; - } - - /** - * Get the SQL to generate the indexes of the given model. - * - * @param - * Object Model - * @return array Array of SQL strings ready to execute. - */ - function getSqlIndexes($model) - { - $index = array(); - foreach ($model->_a['idx'] as $idx => $val) { - if (! isset($val['col'])) { - $val['col'] = $idx; - } - if ($val['type'] == 'unique') { - $unique = 'UNIQUE '; - } else { - $unique = ''; - } - - $index[$this->con->pfx . $model->_a['table'] . '_' . $idx] = sprintf('CREATE ' . $unique . 'INDEX %s ON %s (%s);', $this->con->pfx . $model->_a['table'] . '_' . $idx, $this->con->pfx . $model->_a['table'], self::quoteColumn($val['col'], $this->con)); - } - foreach ($model->_a['cols'] as $col => $val) { - if (isset($val['unique']) and $val['unique'] == true) { - $index[$this->con->pfx . $model->_a['table'] . '_' . $col . '_unique'] = sprintf('CREATE UNIQUE INDEX %s ON %s (%s);', $this->con->pfx . $model->_a['table'] . '_' . $col . '_unique_idx', $this->con->pfx . $model->_a['table'], self::quoteColumn($col, $this->con)); - } - } - return $index; - } - - /** - * All identifiers in Postgres must not exceed 64 characters in length. - * - * @param - * string - * @return string - */ - function getShortenedIdentifierName($name) - { - if (strlen($name) <= 64) { - return $name; - } - return substr($name, 0, 55) . '_' . substr(md5($name), 0, 8); - } - - /** - * Get the SQL to create the constraints for the given model - * - * @param - * Object Model - * @return array Array of SQL strings ready to execute. - */ - function getSqlCreateConstraints($model) - { - $table = $this->con->pfx . $model->_a['table']; - $constraints = array(); - $alter_tbl = 'ALTER TABLE ' . $table; - $cols = $model->_a['cols']; - $manytomany = array(); - - foreach ($cols as $col => $val) { - $field = new $val['type'](); - // remember these for later - if ($field->type == 'manytomany') { - $manytomany[] = $col; - } - if ($field->type == Engine::FOREIGNKEY) { - // Add the foreignkey constraints - $referto = new $val['model'](); - $constraints[] = $alter_tbl . ' ADD CONSTRAINT ' . $this->getShortenedIdentifierName($table . '_' . $col . '_fkey') . ' - FOREIGN KEY (' . $this->con->qn($col) . ') - REFERENCES ' . $this->con->pfx . $referto->_a['table'] . ' (id) MATCH SIMPLE - ON UPDATE NO ACTION ON DELETE NO ACTION'; - } - } - - // Now for the many to many - foreach ($manytomany as $many) { - $omodel = new $cols[$many]['model'](); - $table = Pluf_ModelUtils::getAssocTable($model, $omodel); - - $alter_tbl = 'ALTER TABLE ' . $table; - $constraints[] = $alter_tbl . ' ADD CONSTRAINT ' . $this->getShortenedIdentifierName($table . '_fkey1') . ' - FOREIGN KEY (' . strtolower($model->_a['model']) . '_id) - REFERENCES ' . $this->con->pfx . $model->_a['table'] . ' (id) MATCH SIMPLE - ON UPDATE NO ACTION ON DELETE NO ACTION'; - $constraints[] = $alter_tbl . ' ADD CONSTRAINT ' . $this->getShortenedIdentifierName($table . '_fkey2') . ' - FOREIGN KEY (' . strtolower($omodel->_a['model']) . '_id) - REFERENCES ' . $this->con->pfx . $omodel->_a['table'] . ' (id) MATCH SIMPLE - ON UPDATE NO ACTION ON DELETE NO ACTION'; - } - return $constraints; - } - - /** - * Get the SQL to drop the tables corresponding to the model. - * - * @param - * Object Model - * @return string SQL string ready to execute. - */ - function getSqlDelete($model) - { - $cols = $model->_a['cols']; - $manytomany = array(); - $sql = array(); - $sql[] = 'DROP TABLE IF EXISTS ' . $this->con->pfx . $model->_a['table'] . ' CASCADE'; - foreach ($cols as $col => $val) { - $field = new $val['type'](); - if ($field->type == 'manytomany') { - $manytomany[] = $col; - } - } - - // Now for the many to many - foreach ($manytomany as $many) { - $omodel = new $cols[$many]['model'](); - $table = Pluf_ModelUtils::getAssocTable($model, $omodel); - $sql[] = 'DROP TABLE IF EXISTS ' . $table . ' CASCADE'; - } - return $sql; - } - - /** - * Get the SQL to drop the constraints for the given model - * - * @param - * Object Model - * @return array Array of SQL strings ready to execute. - */ - function getSqlDeleteConstraints($model) - { - $table = $this->con->pfx . $model->_a['table']; - $constraints = array(); - $alter_tbl = 'ALTER TABLE ' . $table; - $cols = $model->_a['cols']; - $manytomany = array(); - - foreach ($cols as $col => $val) { - $field = new $val['type'](); - // remember these for later - if ($field->type == 'manytomany') { - $manytomany[] = $col; - } - if ($field->type == Engine::FOREIGNKEY) { - // Add the foreignkey constraints -// $referto = new $val['model'](); - $constraints[] = $alter_tbl . ' DROP CONSTRAINT ' . $this->getShortenedIdentifierName($table . '_' . $col . '_fkey'); - } - } - - // Now for the many to many - foreach ($manytomany as $many) { - $omodel = new $cols[$many]['model'](); - $table = Pluf_ModelUtils::getAssocTable($model, $omodel); - $alter_tbl = 'ALTER TABLE ' . $table; - $constraints[] = $alter_tbl . ' DROP CONSTRAINT ' . $this->getShortenedIdentifierName($table . '_fkey1'); - $constraints[] = $alter_tbl . ' DROP CONSTRAINT ' . $this->getShortenedIdentifierName($table . '_fkey2'); - } - return $constraints; - } - - /** - * Quote the column name. - * - * @param - * string Name of the column - * @return string Escaped name - */ - function qn($col) - { - return '"' . $col . '"'; - } - - public function dropTableQueries(Pluf_Model $model): array - {} - - public function createConstraintQueries(Pluf_Model $model): array - {} - - public function dropConstraintQueries(Pluf_Model $model): array - {} - - public function createTableQueries(Pluf_Model $model): array - {} - - public function createIndexQueries(Pluf_Model $model): array - {} -} - diff --git a/src6/Db/Query.php b/src6/Db/Query.php new file mode 100644 index 00000000..a80486b0 --- /dev/null +++ b/src6/Db/Query.php @@ -0,0 +1,1642 @@ +field('name'); + * + * You can use a dot to prepend table name to the field: + * $q->field('user.name'); + * $q->field('user.name')->field('address.line1'); + * + * Array as a first argument will specify multiple fields, same as calling field() multiple times + * $q->field(['name', 'surname', 'address.line1']); + * + * You can pass first argument as Expression or Query + * $q->field( $q->expr('2+2'), 'alias'); // must always use alias + * + * You can use $q->dsql() for subqueries. Subqueries will be wrapped in + * brackets. + * $q->field( $q->dsql()->table('x')... , 'alias'); + * + * Associative array will assume that "key" holds the field alias. + * Value may be field name, Expression or Query. + * $q->field(['alias' => 'name', 'alias2' => 'mother.surname']); + * $q->field(['alias' => $q->expr(..), 'alias2' => $q->dsql()->.. ]); + * + * If you need to use funky name for the field (e.g, one containing + * a dot or a space), you should wrap it into expression: + * $q->field($q->expr('{}', ['fun...ky.field']), 'f'); + * + * @param mixed $field + * Specifies field to select + * @param string $alias + * Specify alias for this field + * + * @return $this + */ + public function field($field, $alias = null) + { + // field is passed as string, may contain commas + if (is_string($field) && strpos($field, ',') !== false) { + $field = explode(',', $field); + } + + // recursively add array fields + if (is_array($field)) { + if ($alias !== null) { + throw new Exception([ + 'Alias must not be specified when $field is an array', + 'alias' => $alias + ]); + } + + foreach ($field as $alias => $f) { + $this->field($f, is_numeric($alias) ? null : $alias); + } + + return $this; + } + + // save field in args + $this->_set_args('field', $alias, $field); + + return $this; + } + + /** + * Returns template component for [field]. + * + * @param bool $add_alias + * Should we add aliases, see _render_field_noalias() + * + * @return string Parsed template chunk + */ + protected function _render_field($add_alias = true) + { + // will be joined for output + $ret = []; + + // If no fields were defined, use defaultField + if (empty($this->args['field'])) { + if ($this->defaultField instanceof Expression) { + return $this->_consume($this->defaultField); + } + + return (string) $this->defaultField; + } + + // process each defined field + foreach ($this->args['field'] as $alias => $field) { + // Do not add alias, if: + // - we don't want aliases OR + // - alias is the same as field OR + // - alias is numeric + if ($add_alias === false || (is_string($field) && $alias === $field) || is_numeric($alias)) { + $alias = null; + } + + // Will parameterize the value and escape if necessary + $field = $this->_consume($field, 'soft-escape'); + + if ($alias) { + // field alias cannot be expression, so simply escape it + $field .= ' ' . $this->_escape($alias); + } + + $ret[] = $field; + } + + return implode(',', $ret); + } + + /** + * Renders part of the template: [field_noalias] + * Do not call directly. + * + * @return string Parsed template chunk + */ + protected function _render_field_noalias() + { + return $this->_render_field(false); + } + + // }}} + + // {{{ Table specification and rendering + + /** + * Specify a table to be used in a query. + * + * @param mixed $table + * Specifies table + * @param string $alias + * Specify alias for this table + * + * @return $this + */ + public function table($table, $alias = null) + { + // comma-separated table names + if (is_string($table) && strpos($table, ',') !== false) { + $table = explode(',', $table); + } + + // array of tables - recursively process each + if (is_array($table)) { + if ($alias !== null) { + throw new Exception([ + 'You cannot use single alias with multiple tables', + 'alias' => $alias + ]); + } + + foreach ($table as $alias => $t) { + if (is_numeric($alias)) { + $alias = null; + } + $this->table($t, $alias); + } + + return $this; + } + + // if table is set as sub-Query, then alias is mandatory + if ($table instanceof self && $alias === null) { + throw new Exception('If table is set as Query, then table alias is mandatory'); + } + + if (is_string($table) && $alias === null) { + $alias = $table; + } + + // main_table will be set only if table() is called once. + // it's used as "default table" when joining with other tables, see join(). + // on multiple calls, main_table will be false and we won't + // be able to join easily anymore. + $this->main_table = ($this->main_table === null && $alias !== null ? $alias : false); + + // save table in args + $this->_set_args('table', $alias, $table); + + return $this; + } + + /** + * Renders part of the template: [table] + * Do not call directly. + * + * @param bool $add_alias + * Should we add aliases, see _render_table_noalias() + * + * @return string Parsed template chunk + */ + protected function _render_table($add_alias = true) + { + // will be joined for output + $ret = []; + + if (empty($this->args['table'])) { + return ''; + } + + // process tables one by one + foreach ($this->args['table'] as $alias => $table) { + + // throw exception if we don't want to add alias and table is defined as Expression + if ($add_alias === false && $table instanceof self) { + throw new Exception('Table cannot be Query in UPDATE, INSERT etc. query modes'); + } + + // Do not add alias, if: + // - we don't want aliases OR + // - alias is the same as table name OR + // - alias is numeric + if ($add_alias === false || (is_string($table) && $alias === $table) || is_numeric($alias)) { + $alias = null; + } + + // consume or escape table + $table = $this->_consume($table, 'soft-escape'); + + // add alias if needed + if ($alias) { + $table .= ' ' . $this->_escape($alias); + } + + $ret[] = $table; + } + + return implode(',', $ret); + } + + /** + * Renders part of the template: [table_noalias] + * Do not call directly. + * + * @return string Parsed template chunk + */ + protected function _render_table_noalias() + { + return $this->_render_table(false); + } + + /** + * Renders part of the template: [from] + * Do not call directly. + * + * @return string Parsed template chunk + */ + protected function _render_from() + { + return empty($this->args['table']) ? '' : 'from'; + } + + // / }}} + + // {{{ with() + + /** + * Specify WITH query to be used. + * + * @param Query $cursor + * Specifies cursor query or array [alias=>query] for adding multiple + * @param string $alias + * Specify alias for this cursor + * @param array $fields + * Optional array of field names used in cursor + * @param bool $recursive + * Is it recursive? + * + * @return $this + */ + public function with(self $cursor, string $alias, ?array $fields = null, bool $recursive = false) + { + // save cursor in args + $this->_set_args('with', $alias, [ + 'cursor' => $cursor, + 'fields' => $fields, + 'recursive' => $recursive + ]); + + return $this; + } + + /** + * Recursive WITH query. + * + * @param Query|array $cursor + * Specifies cursor query or array [alias=>query] for adding multiple + * @param string $alias + * Specify alias for this cursor + * @param array $fields + * Optional array of field names used in cursor + * + * @return $this + */ + public function withRecursive(self $cursor, string $alias, ?array $fields = null) + { + return $this->with($cursor, $alias, $fields, true); + } + + /** + * Renders part of the template: [with] + * Do not call directly. + * + * @return string Parsed template chunk + */ + protected function _render_with() + { + // will be joined for output + $ret = []; + + if (empty($this->args['with'])) { + return ''; + } + + // process each defined cursor + $isRecursive = false; + foreach ($this->args['with'] as $alias => [ + 'cursor' => $cursor, + 'fields' => $fields, + 'recursive' => $recursive + ]) { + // cursor alias cannot be expression, so simply escape it + $s = $this->_escape($alias) . ' '; + + // set cursor fields + if ($fields !== null) { + $s .= '(' . implode(',', array_map([ + $this, + '_escape' + ], $fields)) . ') '; + } + + // will parameterize the value and escape if necessary + $s .= 'as ' . $this->_consume($cursor, 'soft-escape'); + + // is at least one recursive ? + $isRecursive = $isRecursive || $recursive; + + $ret[] = $s; + } + + return 'with ' . ($isRecursive ? 'recursive ' : '') . implode(',', $ret) . ' '; + } + + // / }}} + + // {{{ join() + + /** + * Joins your query with another table. + * Join will use $main_table + * to reference the main table, unless you specify it explicitly. + * + * Examples: + * $q->join('address'); // on user.address_id=address.id + * $q->join('address.user_id'); // on address.user_id=user.id + * $q->join('address a'); // With alias + * $q->join(array('a'=>'address')); // Also alias + * + * Second argument may specify the field of the master table + * $q->join('address', 'billing_id'); + * $q->join('address.code', 'code'); + * $q->join('address.code', 'user.code'); + * + * Third argument may specify which kind of join to use. + * $q->join('address', null, 'left'); + * $q->join('address.code', 'user.code', 'inner'); + * + * Using array syntax you can join multiple tables too + * $q->join(array('a'=>'address', 'p'=>'portfolio')); + * + * You can use expression for more complex joins + * $q->join('address', + * $q->orExpr() + * ->where('user.billing_id=address.id') + * ->where('user.technical_id=address.id') + * ) + * + * @param string|array $foreign_table + * Table to join with + * @param mixed $master_field + * Field in master table + * @param string $join_kind + * 'left' or 'inner', etc + * @param string $_foreign_alias + * Internal, don't use + * + * @return $this + */ + public function join($foreign_table, $master_field = null, $join_kind = null, $_foreign_alias = null) + { + // If array - add recursively + if (is_array($foreign_table)) { + foreach ($foreign_table as $alias => $foreign) { + if (is_numeric($alias)) { + $alias = null; + } + + $this->join($foreign, $master_field, $join_kind, $alias); + } + + return $this; + } + $j = []; + + // try to find alias in foreign table definition. this behaviour should be deprecated + if ($_foreign_alias === null) { + list ($foreign_table, $_foreign_alias) = array_pad(explode(' ', $foreign_table, 2), 2, null); + } + + // Split and deduce fields + // NOTE that this will not allow table names with dots in there !!! + list ($f1, $f2) = array_pad(explode('.', $foreign_table, 2), 2, null); + + if (is_object($master_field)) { + $j['expr'] = $master_field; + } else { + // Split and deduce primary table + if ($master_field === null) { + list ($m1, $m2) = [ + null, + null + ]; + } else { + list ($m1, $m2) = array_pad(explode('.', $master_field, 2), 2, null); + } + if ($m2 === null) { + $m2 = $m1; + $m1 = null; + } + if ($m1 === null) { + $m1 = $this->main_table; + } + + // Identify fields we use for joins + if ($f2 === null && $m2 === null) { + $m2 = $f1 . '_id'; + } + if ($m2 === null) { + $m2 = 'id'; + } + $j['m1'] = $m1; + $j['m2'] = $m2; + } + + $j['f1'] = $f1; + if ($f2 === null) { + $f2 = 'id'; + } + $j['f2'] = $f2; + + $j['t'] = $join_kind ?: 'left'; + $j['fa'] = $_foreign_alias; + + $this->args['join'][] = $j; + + return $this; + } + + /** + * Renders [join]. + * + * @return string rendered SQL chunk + */ + public function _render_join() + { + if (! isset($this->args['join'])) { + return ''; + } + $joins = []; + foreach ($this->args['join'] as $j) { + $jj = ''; + + $jj .= $j['t'] . ' join '; + + $jj .= $this->_escapeSoft($j['f1']); + + if ($j['fa'] !== null) { + $jj .= ' as ' . $this->_escape($j['fa']); + } + + $jj .= ' on '; + + if (isset($j['expr'])) { + $jj .= $this->_consume($j['expr']); + } else { + $jj .= $this->_escape($j['fa'] ?: $j['f1']) . '.' . $this->_escape($j['f2']) . ' = ' . ($j['m1'] === null ? '' : $this->_escape($j['m1']) . '.') . $this->_escape($j['m2']); + } + $joins[] = $jj; + } + + return ' ' . implode(' ', $joins); + } + + // }}} + + // {{{ where() and having() specification and rendering + + /** + * Adds condition to your query. + * + * Examples: + * $q->where('id',1); + * + * By default condition implies equality. You can specify a different comparison + * operator by either including it along with the field or using 3-argument + * format: + * $q->where('id>','1'); + * $q->where('id','>',1); + * + * You may use Expression as any part of the query. + * $q->where($q->expr('a=b')); + * $q->where('date>',$q->expr('now()')); + * $q->where($q->expr('length(password)'),'>',5); + * + * If you specify Query as an argument, it will be automatically + * surrounded by brackets: + * $q->where('user_id',$q->dsql()->table('users')->field('id')); + * + * You can specify OR conditions by passing single argument - array: + * $q->where([ + * ['a','is',null], + * ['b','is',null] + * ]); + * + * If entry of the OR condition is not an array, then it's assumed to + * be an expression; + * + * $q->where([ + * ['age',20], + * 'age is null' + * ]); + * + * The above use of OR conditions rely on orExpr() functionality. See + * that method for more information. + * + * To specify OR conditions + * $q->where($q->orExpr()->where('a',1)->where('b',1)); + * + * @param mixed $field + * Field, array for OR or Expression + * @param mixed $cond + * Condition such as '=', '>' or 'is not' + * @param mixed $value + * Value. Will be quoted unless you pass expression + * @param string $kind + * Do not use directly. Use having() + * @param string $num_args + * When $kind is passed, we can't determine number of + * actual arguments, so this argument must be specified. + * + * @return $this + */ + public function where($field, $cond = null, $value = null, $kind = 'where', $num_args = null) + { + // Number of passed arguments will be used to determine if arguments were specified or not + if ($num_args === null) { + $num_args = func_num_args(); + } + + // Array as first argument means we have to replace it with orExpr() + if (is_array($field)) { + // or conditions + $or = $this->orExpr(); + foreach ($field as $row) { + if (is_array($row)) { + call_user_func_array([ + $or, + 'where' + ], $row); + } else { + $or->where($row); + } + } + $field = $or; + } + + if ($num_args === 1 && is_string($field)) { + $this->args[$kind][] = [ + $this->expr($field) + ]; + + return $this; + } + + // first argument is string containing more than just a field name and no more than 2 + // arguments means that we either have a string expression or embedded condition. + if ($num_args === 2 && is_string($field) && ! preg_match('/^[.a-zA-Z0-9_]*$/', $field)) { + // field contains non-alphanumeric values. Look for condition + preg_match('/^([^ <>!=]*)([>expr($field); + + $cond = '='; + } else { + $num_args ++; + } + + $field = $matches[1]; + } + + switch ($num_args) { + case 1: + $this->args[$kind][] = [ + $field + ]; + break; + case 2: + if (is_object($cond) && ! $cond instanceof Expressionable && ! $cond instanceof Expression) { + throw new Exception([ + 'Value cannot be converted to SQL-compatible expression', + 'field' => $field, + 'value' => $cond + ]); + } + + $this->args[$kind][] = [ + $field, + $cond + ]; + break; + case 3: + if (is_object($value) && ! $value instanceof Expressionable && ! $value instanceof Expression) { + throw new Exception([ + 'Value cannot be converted to SQL-compatible expression', + 'field' => $field, + 'cond' => $cond, + 'value' => $value + ]); + } + + $this->args[$kind][] = [ + $field, + $cond, + $value + ]; + break; + } + + return $this; + } + + /** + * Same syntax as where(). + * + * @param mixed $field + * Field, array for OR or Expression + * @param string $cond + * Condition such as '=', '>' or 'is not' + * @param string $value + * Value. Will be quoted unless you pass expression + * + * @return $this + */ + public function having($field, $cond = null, $value = null) + { + $num_args = func_num_args(); + + return $this->where($field, $cond, $value, 'having', $num_args); + } + + /** + * Subroutine which renders either [where] or [having]. + * + * @param string $kind + * 'where' or 'having' + * + * @return array Parsed chunks of query + */ + protected function _sub_render_where($kind) + { + // will be joined for output + $ret = []; + + // where() might have been called multiple times. Collect all conditions, + // then join them with AND keyword + foreach ($this->args[$kind] as $row) { + $ret[] = $this->_sub_render_condition($row); + } + + return $ret; + } + + /** + * Renders one condition. + * + * @param array $row + * Condition + * + * @return string + */ + protected function _sub_render_condition($row) + { + if (count($row) === 3) { + list ($field, $cond, $value) = $row; + } elseif (count($row) === 2) { + list ($field, $cond) = $row; + } elseif (count($row) === 1) { + list ($field) = $row; + } + + $field = $this->_consume($field, 'soft-escape'); + + if (count($row) == 1) { + // Only a single parameter was passed, so we simply include all + return $field; + } + + // below are only cases when 2 or 3 arguments are passed + + // if no condition defined - set default condition + if (count($row) == 2) { + $value = $cond; + + if (is_array($value)) { + $cond = 'in'; + } elseif ($value instanceof self && $value->mode === 'select') { + $cond = 'in'; + } else { + $cond = '='; + } + } else { + $cond = trim(strtolower($cond)); + } + + // below we can be sure that all 3 arguments has been passed + + // special conditions (IS | IS NOT) if value is null + if ($value === null) { + if ($cond === '=') { + $cond = 'is'; + } elseif (in_array($cond, [ + '!=', + '<>', + 'not' + ])) { + $cond = 'is not'; + } + } + + // value should be array for such conditions + if (in_array($cond, [ + 'in', + 'not in', + 'not' + ]) && is_string($value)) { + $value = array_map('trim', explode(',', $value)); + } + + // special conditions (IN | NOT IN) if value is array + if (is_array($value)) { + $cond = in_array($cond, [ + '!=', + '<>', + 'not', + 'not in' + ]) ? 'not in' : 'in'; + + // special treatment of empty array condition + if (empty($value)) { + if ($cond == 'in') { + return $field . '<>' . $field; // never true + } + + return '(' . $field . '=' . $field . ' or ' . $field . ' is null)'; // always true + } + + $value = '(' . implode(',', $this->_param($value)) . ')'; + + return $field . ' ' . $cond . ' ' . $value; + } + + // if value is object, then it should be Expression or Query itself + // otherwise just escape value + $value = $this->_consume($value, 'param'); + + return $field . ' ' . $cond . ' ' . $value; + } + + /** + * Renders [where]. + * + * @return string rendered SQL chunk + */ + protected function _render_where() + { + if (! isset($this->args['where'])) { + return; + } + + return ' where ' . implode(' and ', $this->_sub_render_where('where')); + } + + /** + * Renders [orwhere]. + * + * @return string rendered SQL chunk + */ + protected function _render_orwhere() + { + if (! isset($this->args['where'])) { + return; + } + + return implode(' or ', $this->_sub_render_where('where')); + } + + /** + * Renders [andwhere]. + * + * @return string rendered SQL chunk + */ + protected function _render_andwhere() + { + if (! isset($this->args['where'])) { + return; + } + + return implode(' and ', $this->_sub_render_where('where')); + } + + /** + * Renders [having]. + * + * @return string rendered SQL chunk + */ + protected function _render_having() + { + if (! isset($this->args['having'])) { + return; + } + + return ' having ' . implode(' and ', $this->_sub_render_where('having')); + } + + // }}} + + // {{{ group() + + /** + * Implements GROUP BY functionality. + * Simply pass either field name + * as string or expression. + * + * @param mixed $group + * Group by this + * + * @return $this + */ + public function group($group) + { + // Case with comma-separated fields + if (is_string($group) && ! $this->isUnescapablePattern($group) && strpos($group, ',') !== false) { + $group = explode(',', $group); + } + + if (is_array($group)) { + foreach ($group as $g) { + $this->args['group'][] = $g; + } + + return $this; + } + + $this->args['group'][] = $group; + + return $this; + } + + /** + * Renders [group]. + * + * @return string rendered SQL chunk + */ + protected function _render_group() + { + if (! isset($this->args['group'])) { + return ''; + } + + $g = array_map(function ($a) { + return $this->_consume($a, 'soft-escape'); + }, $this->args['group']); + + return ' group by ' . implode(', ', $g); + } + + // }}} + + // {{{ Set field implementation + + /** + * Sets field value for INSERT or UPDATE statements. + * + * @param string|array $field + * Name of the field + * @param mixed $value + * Value of the field + * + * @return $this + */ + public function set($field, $value = null) + { + if ($value === false) { + throw new Exception([ + 'Value "false" is not supported by SQL', + 'field' => $field, + 'value' => $value + ]); + } + + if (is_array($value)) { + throw new Exception([ + 'Array values are not supported by SQL', + 'field' => $field, + 'value' => $value + ]); + } + + if (is_array($field)) { + foreach ($field as $key => $value) { + $this->set($key, $value); + } + + return $this; + } + + if (is_string($field) || $field instanceof Expression || $field instanceof Expressionable) { + $this->args['set'][] = [ + $field, + $value + ]; + } else { + throw new Exception([ + 'Field name should be string or Expressionable', + 'field' => $field + ]); + } + + return $this; + } + + /** + * Renders [set] for UPDATE query. + * + * @return string rendered SQL chunk + */ + protected function _render_set() + { + // will be joined for output + $ret = []; + + if (isset($this->args['set']) && $this->args['set']) { + foreach ($this->args['set'] as list ($field, $value)) { + $field = $this->_consume($field, 'escape'); + $value = $this->_consume($value, 'param'); + + $ret[] = $field . '=' . $value; + } + } + + return implode(', ', $ret); + } + + /** + * Renders [set_fields] for INSERT. + * + * @return string rendered SQL chunk + */ + protected function _render_set_fields() + { + // will be joined for output + $ret = []; + + if ($this->args['set']) { + foreach ($this->args['set'] as list ($field /* , $value */)) { + $field = $this->_consume($field, 'escape'); + + $ret[] = $field; + } + } + + return implode(',', $ret); + } + + /** + * Renders [set_values] for INSERT. + * + * @return string rendered SQL chunk + */ + protected function _render_set_values() + { + // will be joined for output + $ret = []; + + if ($this->args['set']) { + foreach ($this->args['set'] as list (/*$field*/, $value)) { + $value = $this->_consume($value, 'param'); + + $ret[] = $value; + } + } + + return implode(',', $ret); + } + + // }}} + + // {{{ Option + + /** + * Set options for particular mode. + * + * @param mixed $option + * @param string $mode + * select|insert|replace + * + * @return $this + */ + public function option($option, $mode = 'select') + { + // Case with comma-separated options + if (is_string($option) && strpos($option, ',') !== false) { + $option = explode(',', $option); + } + + if (is_array($option)) { + foreach ($option as $opt) { + $this->args['option'][$mode][] = $opt; + } + + return $this; + } + + $this->args['option'][$mode][] = $option; + + return $this; + } + + /** + * Renders [option]. + * + * @return string rendered SQL chunk + */ + protected function _render_option() + { + if (! isset($this->args['option'][$this->mode])) { + return ''; + } + + return ' ' . implode(' ', $this->args['option'][$this->mode]); + } + + // }}} + + // {{{ Query Modes + + /** + * Execute select statement. + * + * @return \PDOStatement + */ + public function select() + { + return $this->mode('select')->execute(); + } + + /** + * Execute insert statement. + * + * @return \PDOStatement + */ + public function insert() + { + return $this->mode('insert')->execute(); + } + + /** + * Execute update statement. + * + * @return \PDOStatement + */ + public function update() + { + return $this->mode('update')->execute(); + } + + /** + * Execute replace statement. + * + * @return \PDOStatement + */ + public function replace() + { + return $this->mode('replace')->execute(); + } + + /** + * Execute delete statement. + * + * @return \PDOStatement + */ + public function delete() + { + return $this->mode('delete')->execute(); + } + + /** + * Execute truncate statement. + * + * @return \PDOStatement + */ + public function truncate() + { + return $this->mode('truncate')->execute(); + } + + // }}} + + // {{{ Limit + + /** + * Limit how many rows will be returned. + * + * @param int $cnt + * Number of rows to return + * @param int $shift + * Offset, how many rows to skip + * + * @return $this + */ + public function limit($cnt, $shift = null) + { + $this->args['limit'] = [ + 'cnt' => $cnt, + 'shift' => $shift + ]; + + return $this; + } + + /** + * Renders [limit]. + * + * @return string rendered SQL chunk + */ + public function _render_limit() + { + if (isset($this->args['limit'])) { + return ' limit ' . (int) $this->args['limit']['shift'] . ', ' . (int) $this->args['limit']['cnt']; + } + } + + // }}} + + // {{{ Order + + /** + * Orders results by field or Expression. + * See documentation for full + * list of possible arguments. + * + * $q->order('name'); + * $q->order('name desc'); + * $q->order('name desc, id asc') + * $q->order('name',true); + * + * @param string|array $order + * Order by + * @param string|bool $desc + * true to sort descending + * + * @return $this + */ + public function order($order, $desc = null) + { + // Case with comma-separated fields or first argument being an array + if (is_string($order) && strpos($order, ',') !== false) { + $order = explode(',', $order); + } + + if (is_array($order)) { + if ($desc !== null) { + throw new Exception('If first argument is array, second argument must not be used'); + } + foreach (array_reverse($order) as $o) { + $this->order($o); + } + + return $this; + } + + // First argument may contain space, to divide field and ordering keyword. + // Explode string only if ordering keyword is 'desc' or 'asc'. + if ($desc === null && is_string($order) && strpos($order, ' ') !== false) { + $_chunks = explode(' ', $order); + $_desc = strtolower(array_pop($_chunks)); + if (in_array($_desc, [ + 'desc', + 'asc' + ])) { + $order = implode(' ', $_chunks); + $desc = $_desc; + } + } + + if (is_bool($desc)) { + $desc = $desc ? 'desc' : ''; + } elseif (strtolower($desc) === 'asc') { + $desc = ''; + } else { + // allows custom order like "order by name desc nulls last" for Oracle + } + + $this->args['order'][] = [ + $order, + $desc + ]; + + return $this; + } + + /** + * Renders [order]. + * + * @return string rendered SQL chunk + */ + public function _render_order() + { + if (! isset($this->args['order'])) { + return ''; + } + + $x = []; + foreach ($this->args['order'] as $tmp) { + list ($arg, $desc) = $tmp; + $x[] = $this->_consume($arg, 'soft-escape') . ($desc ? (' ' . $desc) : ''); + } + + return ' order by ' . implode(', ', array_reverse($x)); + } + + // }}} + public function __debugInfo() + { + $arr = [ + 'R' => false, + 'mode' => $this->mode + // 'template' => $this->template, + // 'params' => $this->params, + // 'connection' => $this->connection, + // 'main_table' => $this->main_table, + // 'args' => $this->args, + ]; + + try { + $arr['R'] = $this->getDebugQuery(); + } catch (\Exception $e) { + $arr['R'] = $e->getMessage(); + } + + return $arr; + } + + // {{{ Miscelanious + + /** + * Renders query template. + * If the template is not explicitly set will use "select" mode. + * + * @return string + */ + public function render() + { + if (! $this->template) { + $this->mode('select'); + } + + return parent::render(); + } + + /** + * Switch template for this query. + * Determines what would be done + * on execute. + * + * By default it is in SELECT mode + * + * @param string $mode + * + * @return $this + */ + public function mode($mode) + { + $prop = 'template_' . $mode; + + if (isset($this->{$prop})) { + $this->mode = $mode; + $this->template = $this->{$prop}; + } else { + throw new Exception([ + 'Query does not have this mode', + 'mode' => $mode + ]); + } + + return $this; + } + + /** + * Use this instead of "new Query()" if you want to automatically bind + * query to the same connection as the parent. + * + * @param array $properties + * + * @return Query + */ + public function dsql($properties = []) + { + $q = new static($properties); + $q->connection = $this->connection; + + return $q; + } + + /** + * Returns Expression object for the corresponding Query + * sub-class (e.g. + * Query_MySQL will return Expression_MySQL). + * + * Connection is not mandatory, but if set, will be preserved. This + * method should be used for building parts of the query internally. + * + * @param array $properties + * @param array $arguments + * + * @return Expression + */ + public function expr($properties = [], $arguments = null) + { + $c = $this->expression_class; + $e = new $c($properties, $arguments); + $e->connection = $this->connection; + + return $e; + } + + /** + * Returns Expression object for NOW() or CURRENT_TIMESTAMP() method. + * + * @param int $precision + * + * @return Expression + */ + public function exprNow($precision = null) + { + if ($precision !== null) { + return $this->expr('current_timestamp([])', [ + $precision + ]); + } + + return $this->expr('current_timestamp()'); + } + + /** + * Returns new Query object of [or] expression. + * + * @return Query + */ + public function orExpr() + { + return $this->dsql([ + 'template' => '[orwhere]' + ]); + } + + /** + * Returns new Query object of [and] expression. + * + * @return Query + */ + public function andExpr() + { + return $this->dsql([ + 'template' => '[andwhere]' + ]); + } + + /** + * Returns Query object of [case] expression. + * + * @param mixed $operand + * Optional operand for case expression. + * + * @return Query + */ + public function caseExpr($operand = null) + { + $q = $this->dsql([ + 'template' => '[case]' + ]); + + if ($operand !== null) { + $q->args['case_operand'] = $operand; + } + + return $q; + } + + /** + * Returns a query for a function, which can be used as part of the GROUP + * query which would concatenate all matching fields. + * + * MySQL, SQLite - group_concat + * PostgreSQL - string_agg + * Oracle - listagg + * + * @param mixed $field + * @param string $delimiter + * + * @return Expression + */ + public function groupConcat($field, $delimeter = ',') + { + throw new Exception('groupConcat() is SQL-dependent, so use a correct class'); + } + + /** + * Add when/then condition for [case] expression. + * + * @param mixed $when + * Condition as array for normal form [case] statement or just value in case of short form [case] statement + * @param mixed $then + * Then expression or value + * + * @return $this + */ + public function when($when, $then) + { + $this->args['case_when'][] = [ + $when, + $then + ]; + + return $this; + } + + /** + * Add else condition for [case] expression. + * + * @param mixed $else + * Else expression or value + * + * @return $this + */ + // public function else($else) // PHP 5.6 restricts to use such method name. PHP 7 is fine with it + public function otherwise($else) + { + $this->args['case_else'] = $else; + + return $this; + } + + /** + * Renders [case]. + * + * @return string rendered SQL chunk + */ + protected function _render_case() + { + if (! isset($this->args['case_when'])) { + return; + } + + $ret = ''; + + // operand + if ($short_form = isset($this->args['case_operand'])) { + $ret .= ' ' . $this->_consume($this->args['case_operand'], 'soft-escape'); + } + + // when, then + foreach ($this->args['case_when'] as $row) { + if (! array_key_exists(0, $row) || ! array_key_exists(1, $row)) { + throw new Exception([ + 'Incorrect use of "when" method parameters', + 'row' => $row + ]); + } + + $ret .= ' when '; + if ($short_form) { + // short-form + if (is_array($row[0])) { + throw new Exception([ + 'When using short form CASE statement, then you should not set array as when() method 1st parameter', + 'when' => $row[0] + ]); + } + $ret .= $this->_consume($row[0], 'param'); + } else { + $ret .= $this->_sub_render_condition($row[0]); + } + + // then + $ret .= ' then ' . $this->_consume($row[1], 'param'); + } + + // else + if (array_key_exists('case_else', $this->args)) { + $ret .= ' else ' . $this->_consume($this->args['case_else'], 'param'); + } + + return ' case' . $ret . ' end'; + } + + /** + * Sets value in args array. + * Doesn't allow duplicate aliases. + * + * @param string $what + * Where to set it - table|field + * @param string $alias + * Alias name + * @param mixed $value + * Value to set in args array + */ + protected function _set_args($what, $alias, $value) + { + // save value in args + if ($alias === null) { + $this->args[$what][] = $value; + } else { + + // don't allow multiple values with same alias + if (isset($this->args[$what][$alias])) { + throw new Exception([ + 'Alias should be unique', + 'what' => $what, + 'alias' => $alias + ]); + } + + $this->args[$what][$alias] = $value; + } + } + + // / }}} +} diff --git a/src6/Db/Query/MySQL.php b/src6/Db/Query/MySQL.php new file mode 100644 index 00000000..21d9587f --- /dev/null +++ b/src6/Db/Query/MySQL.php @@ -0,0 +1,50 @@ +expr('group_concat({} separator [])', [ + $field, + $delimeter + ]); + } +} diff --git a/src6/Db/Query/Oracle.php b/src6/Db/Query/Oracle.php new file mode 100644 index 00000000..ec91978d --- /dev/null +++ b/src6/Db/Query/Oracle.php @@ -0,0 +1,62 @@ +[limit_start][and_limit_end]'; + + /** + * Limit how many rows will be returned. + * + * @param int $cnt Number of rows to return + * @param int $shift Offset, how many rows to skip + * + * @return $this + */ + public function limit($cnt, $shift = null) + { + // This is for pre- 12c version + $this->template_select = $this->template_select_limit; + + return parent::limit($cnt, $shift); + } + + /** + * Renders [limit_start]. + * + * @return string rendered SQL chunk + */ + public function _render_limit_start() + { + return (int) $this->args['limit']['shift']; + } + + /** + * Renders [and_limit_end]. + * + * @return string rendered SQL chunk + */ + public function _render_and_limit_end() + { + if (!$this->args['limit']['cnt']) { + return ''; + } + + return ' and "__dsql_rownum"<='. + ((int) ($this->args['limit']['cnt'] + $this->args['limit']['shift'])); + } +} diff --git a/src6/Db/Query/Oracle12c.php b/src6/Db/Query/Oracle12c.php new file mode 100644 index 00000000..3ef6fa8e --- /dev/null +++ b/src6/Db/Query/Oracle12c.php @@ -0,0 +1,32 @@ +args['limit'])) { + $cnt = (int) $this->args['limit']['cnt']; + $shift = (int) $this->args['limit']['shift']; + + return ' '.trim( + ($shift ? 'OFFSET '.$shift.' ROWS' : ''). + ' '. + // as per spec 'NEXT' is synonymous to 'FIRST', so not bothering with it. + // https://docs.oracle.com/javadb/10.8.3.0/ref/rrefsqljoffsetfetch.html + ($cnt ? 'FETCH NEXT '.$cnt.' ROWS ONLY' : '') + ); + } + } +} diff --git a/src6/Db/Query/OracleAbstract.php b/src6/Db/Query/OracleAbstract.php new file mode 100644 index 00000000..e6dd8f0f --- /dev/null +++ b/src6/Db/Query/OracleAbstract.php @@ -0,0 +1,76 @@ +args['sequence'] = $sequence; + + return $this; + } + + /** + * Renders [sequence]. + * + * @return string rendered SQL chunk + */ + public function _render_sequence() + { + return $this->args['sequence']; + } + + /** + * Returns a query for a function, which can be used as part of the GROUP + * query which would concatenate all matching fields. + * + * MySQL, SQLite - group_concat + * PostgreSQL - string_agg + * Oracle - listagg + * + * NOTE: LISTAGG() is only supported starting from Oracle 11g and up + * https://stackoverflow.com/a/16771200/1466341 + * + * @param mixed $field + * @param string $delimiter + * + * @return Expression + */ + public function groupConcat($field, $delimeter = ',') + { + return $this->expr('listagg({}, [])', [ + $field, + $delimeter + ]); + } +} diff --git a/src6/Db/Query/PgSQL.php b/src6/Db/Query/PgSQL.php new file mode 100644 index 00000000..b4683556 --- /dev/null +++ b/src6/Db/Query/PgSQL.php @@ -0,0 +1,66 @@ +args['limit'])) { + return ' limit ' . (int) $this->args['limit']['cnt'] . ' offset ' . (int) $this->args['limit']['shift']; + } + } + + /** + * Returns a query for a function, which can be used as part of the GROUP + * query which would concatenate all matching fields. + * + * MySQL, SQLite - group_concat + * PostgreSQL - string_agg + * Oracle - listagg + * + * @param mixed $field + * @param string $delimiter + * + * @return Expression + */ + public function groupConcat($field, $delimeter = ',') : Expression + { + return $this->expr('string_agg({}, [])', [ + $field, + $delimeter + ]); + } +} diff --git a/src6/Db/Query/SQLite.php b/src6/Db/Query/SQLite.php new file mode 100644 index 00000000..dcded869 --- /dev/null +++ b/src6/Db/Query/SQLite.php @@ -0,0 +1,42 @@ +expr('group_concat({}, [])', [ + $field, + $delimeter + ]); + } +} diff --git a/src6/Db/ResultSet.php b/src6/Db/ResultSet.php new file mode 100644 index 00000000..f5a3a83b --- /dev/null +++ b/src6/Db/ResultSet.php @@ -0,0 +1,18 @@ +setDefaults(['ui' => 'segment']); + * + * Typically you would want to do that inside your constructor. The + * default handling of the properties is: + * + * - only apply properties that are defined + * - only set property if it's current value is null + * - ignore defaults that have null value + * - if existing property and default have array, then both arrays will be merged + * + * Several classes may opt to extend setDefaults, for example in UI + * setDefaults is extended to support classes and content: + * + * $segment->setDefaults(['Hello There', 'red', 'ui'=>'segment']); + * + * WARNING: Do not use this trait unless you have a lot of properties + * to inject. Also follow the guidelines on + * + * https://github.com/atk4/ui/wiki/Object-Constructors + * + * Relying on this trait excessively may cause anger management issues to + * some code reviewers. + */ +trait DiContainerTrait +{ + /** + * Check this property to see if trait is present in the object. + * + * @var bool + */ + public $_DIContainerTrait = true; + + /** + * Call from __construct() to initialize the properties allowing + * developer to pass Dependency Injector Container. + * + * @param array $properties + * @param bool $passively if true, existing non-null argument values will be kept + */ + public function setDefaults($properties = [], $passively = false) + { + if ($properties === null) { + $properties = []; + } + + foreach ($properties as $key => $val) { + if (!is_numeric($key) && property_exists($this, $key)) { + if ($passively && $this->$key !== null) { + continue; + } + + if ($val !== null) { + $this->$key = $val; + } + } else { + $this->setMissingProperty($key, $val); + } + } + } + + /** + * Sets object property. + * Throws exception. + * + * @param mixed $key + * @param mixed $value + * @param bool $strict + */ + protected function setMissingProperty($key, $value) + { + // ignore numeric properties by default + if (is_numeric($key)) { + return; + } + + throw new Exception([ + 'Property for specified object is not defined', + 'object' => $this, + 'property'=> $key, + 'value' => $value, + ]); + } +} diff --git a/src6/bootstrap.php b/src6/bootstrap.php deleted file mode 100644 index a5343bc6..00000000 --- a/src6/bootstrap.php +++ /dev/null @@ -1,124 +0,0 @@ -getMessage(); -// // die(); -// // } -// // throw \Pluf\Exception('Class not found:' . $class_name); -// // } -// // } - -// // /* -// // * PHP 5.x support -// // */ -// // spl_autoload_register('Pluf_autoload'); - -// // /** -// // * Exception to catch the PHP errors. -// // * -// // * @credits errd -// // * -// // * @see http://www.php.net/manual/en/function.set-error-handler.php -// // */ -// // class PlufErrorHandlerException extends Exception -// // { - -// // public function setLine($line) -// // { -// // $this->line = $line; -// // } - -// // public function setFile($file) -// // { -// // $this->file = $file; -// // } -// // } - -// // /** -// // * The function that is the real error handler. -// // */ -// // function PlufErrorHandler($code, $string, $file, $line) -// // { -// // if (0 == error_reporting()) -// // return false; -// // if (E_STRICT == $code && (0 === strpos($file, Pluf::f('pear_path', '/usr/share/php/')) or false !== strripos($file, 'pear'))) // if pear in the path, ignore -// // { -// // return; -// // } -// // $exception = new PlufErrorHandlerException($string, $code); -// // $exception->setLine($line); -// // $exception->setFile($file); -// // throw $exception; -// // } - -// // // Set the error handler only if not performing the unittests. -// // if (! defined('IN_UNIT_TESTS')) { -// // set_error_handler('PlufErrorHandler', error_reporting()); -// // } - -// /** -// * Shortcut needed all over the place. -// * -// * Note that in some cases, we need to escape strings not in UTF-8, so -// * this is not possible to safely use a call to htmlspecialchars. This -// * is why str_replace is used. -// * -// * @param -// * string Raw string -// * @return string HTML escaped string -// */ -// function Pluf_esc($string) -// { -// return str_replace(array( -// '&', -// '"', -// '<', -// '>' -// ), array( -// '&', -// '"', -// '<', -// '>' -// ), (string) $string); -// } \ No newline at end of file diff --git a/tests/Db/ConnectionTest.php b/tests/Db/ConnectionTest.php new file mode 100644 index 00000000..37178340 --- /dev/null +++ b/tests/Db/ConnectionTest.php @@ -0,0 +1,357 @@ +assertEquals(4, $c->expr('select (2+2)') + ->getOne()); + } + + /** + * Test DSN normalize. + */ + public function testDSNNormalize() + { + // standard + $dsn = Connection::normalizeDSN('mysql://root:pass@localhost/db'); + $this->assertEquals([ + 'dsn' => 'mysql:host=localhost;dbname=db', + 'user' => 'root', + 'pass' => 'pass', + 'driver' => 'mysql', + 'rest' => 'host=localhost;dbname=db' + ], $dsn); + + $dsn = Connection::normalizeDSN('mysql:host=localhost;dbname=db'); + $this->assertEquals([ + 'dsn' => 'mysql:host=localhost;dbname=db', + 'user' => null, + 'pass' => null, + 'driver' => 'mysql', + 'rest' => 'host=localhost;dbname=db' + ], $dsn); + + $dsn = Connection::normalizeDSN('mysql:host=localhost;dbname=db', 'root', 'pass'); + $this->assertEquals([ + 'dsn' => 'mysql:host=localhost;dbname=db', + 'user' => 'root', + 'pass' => 'pass', + 'driver' => 'mysql', + 'rest' => 'host=localhost;dbname=db' + ], $dsn); + + // username and password should take precedence + $dsn = Connection::normalizeDSN('mysql://root:pass@localhost/db', 'foo', 'bar'); + $this->assertEquals([ + 'dsn' => 'mysql:host=localhost;dbname=db', + 'user' => 'foo', + 'pass' => 'bar', + 'driver' => 'mysql', + 'rest' => 'host=localhost;dbname=db' + ], $dsn); + + // more options + $dsn = Connection::normalizeDSN('mysql://root:pass@localhost/db;foo=bar'); + $this->assertEquals([ + 'dsn' => 'mysql:host=localhost;dbname=db;foo=bar', + 'user' => 'root', + 'pass' => 'pass', + 'driver' => 'mysql', + 'rest' => 'host=localhost;dbname=db;foo=bar' + ], $dsn); + + // no password + $dsn = Connection::normalizeDSN('mysql://root@localhost/db'); + $this->assertEquals([ + 'dsn' => 'mysql:host=localhost;dbname=db', + 'user' => 'root', + 'pass' => null, + 'driver' => 'mysql', + 'rest' => 'host=localhost;dbname=db' + ], $dsn); + $dsn = Connection::normalizeDSN('mysql://root:@localhost/db'); // see : after root + $this->assertEquals([ + 'dsn' => 'mysql:host=localhost;dbname=db', + 'user' => 'root', + 'pass' => null, + 'driver' => 'mysql', + 'rest' => 'host=localhost;dbname=db' + ], $dsn); + + // specific DSNs + $dsn = Connection::normalizeDSN('dumper:sqlite::memory'); + $this->assertEquals([ + 'dsn' => 'dumper:sqlite::memory', + 'user' => null, + 'pass' => null, + 'driver' => 'dumper', + 'rest' => 'sqlite::memory' + ], $dsn); + + $dsn = Connection::normalizeDSN('sqlite::memory'); + $this->assertEquals([ + 'dsn' => 'sqlite::memory', + 'user' => null, + 'pass' => null, + 'driver' => 'sqlite', + 'rest' => ':memory' + ], $dsn); // rest is unusable anyway in this context + + // with port number as URL, normalize port to ;port=1234 + $dsn = Connection::normalizeDSN('mysql://root:pass@localhost:1234/db'); + $this->assertEquals([ + 'dsn' => 'mysql:host=localhost;port=1234;dbname=db', + 'user' => 'root', + 'pass' => 'pass', + 'driver' => 'mysql', + 'rest' => 'host=localhost;port=1234;dbname=db' + ], $dsn); + + // with port number as DSN, leave port as :port + $dsn = Connection::normalizeDSN('mysql:host=localhost:1234;dbname=db'); + $this->assertEquals([ + 'dsn' => 'mysql:host=localhost:1234;dbname=db', + 'user' => null, + 'pass' => null, + 'driver' => 'mysql', + 'rest' => 'host=localhost:1234;dbname=db' + ], $dsn); + } + + /** + * Test driver property. + */ + public function testDriver() + { + $c = Connection::connect('sqlite::memory:'); + $this->assertEquals('sqlite', $c->driver); + + $c = Connection::connect('dumper:sqlite::memory:'); + $this->assertEquals('sqlite', $c->driver); + + $c = Connection::connect('counter:sqlite::memory:'); + $this->assertEquals('sqlite', $c->driver); + } + + /** + * Test Dumper connection. + */ + public function testDumper() + { + $c = Connection::connect('dumper:sqlite::memory:'); + + $result = false; + $c->callback = function ($expr, $time, $fail) use (&$result) { + $result = $expr->render(); + }; + + $this->assertEquals('PDO', get_class($c->connection())); + + $this->assertEquals(4, $c->expr('select (2+2)') + ->getOne()); + + $this->assertEquals('select (2+2)', $result); + } + + /** + * + * @expectedException Exception + */ + public function testMysqlFail() + { + Connection::connect('mysql:host=localhost;dbname=nosuchdb'); + } + + public function testDumperEcho() + { + $c = Connection::connect('dumper:sqlite::memory:'); + + $this->assertEquals(4, $c->expr('select (2+2)') + ->getOne()); + + $this->expectOutputRegex("/select \(2\+2\)/"); + } + + public function testCounter() + { + $c = Connection::connect('counter:sqlite::memory:'); + + $result = false; + $c->callback = function ($a, $b, $c, $d, $fail) use (&$result) { + $result = [ + $a, + $b, + $c, + $d + ]; + }; + + $this->assertEquals(4, $c->expr('select ([]+[])', [ + $c->expr('2'), + 2 + ]) + ->getOne()); + + unset($c); + $this->assertEquals([ + 0, + 0, + 1, + 1 + ], $result); + } + + public function testCounterEcho() + { + $c = Connection::connect('counter:sqlite::memory:'); + + $this->assertEquals(4, $c->expr('select ([]+[])', [ + $c->expr('2'), + 2 + ]) + ->getOne()); + + $this->expectOutputString("Queries: 0, Selects: 0, Rows fetched: 1, Expressions 1\n"); + + unset($c); + } + + public function testCounter2() + { + $c = Connection::connect('counter:sqlite::memory:'); + + $result = false; + $c->callback = function ($a, $b, $c, $d, $fail) use (&$result) { + $result = [ + $a, + $b, + $c, + $d + ]; + }; + + $this->assertEquals(4, $c->dsql() + ->field($c->expr('2+2')) + ->getOne()); + + unset($c); + $this->assertEquals([ + 1, + 1, + 1, + 0 + ], + // 1 query + // 1 select + // 1 result row + // 0 expressions + $result); + } + + public function testCounter3() + { + $c = Connection::connect('counter:sqlite::memory:'); + + $result = false; + $c->callback = function ($a, $b, $c, $d, $fail) use (&$result) { + $result = [ + $a, + $b, + $c, + $d + ]; + }; + + $c->expr('create table test (id int, name varchar(255))')->execute(); + $c->dsql() + ->table('test') + ->set('name', 'John') + ->insert(); + $c->dsql() + ->table('test') + ->set('name', 'Peter') + ->insert(); + $c->dsql() + ->table('test') + ->set('name', 'Joshua') + ->insert(); + $res = $c->dsql() + ->table('test') + ->where('name', 'like', 'J%') + ->field('name') + ->get(); + + $this->assertEquals([ + [ + 'name' => 'John' + ], + [ + 'name' => 'Joshua' + ] + ], $res); + + unset($c); + $this->assertEquals([ + 4, + 1, + 2, + 1 + ], + // 4 queries, 3 inserts and select + // 1 select + // 2 result row, john, joshua + // 1 expressions, create + $result); + } + + /** + * + * @expectedException \PDOException + */ + public function testException1() + { + Connection::connect(':'); + } + + /** + * + * @expectedException \Pluf\Db\Exception + */ + public function testException2() + { + Connection::connect(''); + } + + /** + * + * @expectedException \Pluf\Db\Exception + */ + public function testException3() + { + new Connection('sqlite::memory'); + } + + /** + * + * @expectedException \Pluf\Db\Exception + */ + public function testException4() + { + $c = new Connection(); + $q = $c->expr('select (2+2)'); + + $this->assertEquals('select (2+2)', $q->render()); + + $q->execute(); + } +} diff --git a/tests/Db/ExceptionTest.php b/tests/Db/ExceptionTest.php new file mode 100644 index 00000000..3b0225e2 --- /dev/null +++ b/tests/Db/ExceptionTest.php @@ -0,0 +1,41 @@ +render(); + } + +// public function testException3() +// { +// try { +// $e = new Expression('hello, [world]'); +// $e->render(); +// } catch (Exception $e) { +// $this->assertEquals('Expression could not render tag', $e->getMessage()); +// $this->assertEquals('world', $e->getParams()['tag']); +// } +// } +} diff --git a/tests/Db/ExpressionTest.php b/tests/Db/ExpressionTest.php new file mode 100644 index 00000000..872a7eab --- /dev/null +++ b/tests/Db/ExpressionTest.php @@ -0,0 +1,553 @@ +e(null); + } + + /** + * Test constructor exception - wrong 1st parameter. + * + * @expectedException \Pluf\Db\Exception + */ + public function testConstructorException_1st_2() + { + $this->e(false); + } + + /** + * Test constructor exception - wrong 2nd parameter. + * + * @expectedException \Pluf\Db\Exception + */ + public function testConstructorException_2nd_1() + { + $this->e('hello, []', false); + } + + /** + * Test constructor exception - wrong 2nd parameter. + * + * @expectedException \Pluf\Db\Exception + */ + public function testConstructorException_2nd_2() + { + $this->e('hello, []', 'hello'); + } + + /** + * Test constructor exception - no arguments. + * + * @expectedException \Pluf\Db\Exception + */ + public function testConstructorException_0arg() + { + // Template is not defined for Expression + $this->e()->render(); + } + + /** + * Testing parameter edge cases - empty strings and arrays etc. + */ + public function testConstructor_1() + { + $this->assertEquals('', $this->e('') + ->render()); + } + + /** + * Testing simple template patterns without arguments. + * Testing different ways how to pass template to constructor. + */ + public function testConstructor_2() + { + // pass as string + $this->assertEquals('now()', $this->e('now()') + ->render()); + // pass as array without key + $this->assertEquals('now()', $this->e([ + 'now()' + ]) + ->render()); + // pass as array with template key + $this->assertEquals('now()', $this->e([ + 'template' => 'now()' + ]) + ->render()); + // pass as array without key + $this->assertEquals(':a Name', $this->e([ + '[] Name' + ], [ + 'First' + ]) + ->render()); + // pass as array with template key + $this->assertEquals(':a Name', $this->e([ + 'template' => '[] Name' + ], [ + 'Last' + ]) + ->render()); + } + + /** + * Testing template with simple arguments. + */ + public function testConstructor_3() + { + $e = $this->e('hello, [who]', [ + 'who' => 'world' + ]); + $this->assertEquals('hello, :a', $e->render()); + $this->assertEquals('world', $e->params[':a']); + + $e = $this->e('hello, {who}', [ + 'who' => 'world' + ]); + $this->assertEquals('hello, "world"', $e->render()); + $this->assertEquals([], $e->params); + } + + /** + * Testing template with complex arguments. + */ + public function testConstructor_4() + { + // argument = Expression + $this->assertEquals('hello, world', $this->e('hello, [who]', [ + 'who' => $this->e('world') + ]) + ->render()); + + // multiple arguments = Expression + $this->assertEquals('hello, world', $this->e('[what], [who]', [ + 'what' => $this->e('hello'), + 'who' => $this->e('world') + ]) + ->render()); + + // numeric argument = Expression + $this->assertEquals('testing "hello, world"', $this->e('testing "[]"', [ + $this->e('[what], [who]', [ + 'what' => $this->e('hello'), + 'who' => $this->e('world') + ]) + ]) + ->render()); + + // pass template as array + $this->assertEquals('hello, world', $this->e([ + 'template' => 'hello, [who]' + ], [ + 'who' => $this->e('world') + ]) + ->render()); + } + + /** + * Test nested parameters. + */ + public function testNestedParams() + { + // ++1 and --2 + $e1 = $this->e('[] and []', [ + $this->e('++[]', [ + 1 + ]), + $this->e('--[]', [ + 2 + ]) + ]); + + $this->assertEquals('++1 and --2', strip_tags($e1->getDebugQuery())); + + $e2 = $this->e('=== [foo] ===', [ + 'foo' => $e1 + ]); + + $this->assertEquals('=== ++1 and --2 ===', strip_tags($e2->getDebugQuery())); + + $this->assertEquals('++1 and --2', strip_tags($e1->getDebugQuery())); + } + + /** + * Tests where one expression with parameter is used within several other expressions. + */ + public function testNestedExpressions() + { + $e1 = $this->e('Hello [who]', [ + 'who' => 'world' + ]); + + $e2 = $this->e('[greeting]! How are you.', [ + 'greeting' => $e1 + ]); + $e3 = $this->e('It is me again. [greeting]', [ + 'greeting' => $e1 + ]); + + $s2 = $e2->render(); // Hello :a! How are you. + $s3 = $e3->render(); // It is me again. Hello :a + + $e4 = $this->e('[] and good night', [ + $e1 + ]); + $s4 = $e4->render(); // Hello :a and good night + + $this->assertEquals('Hello :a! How are you.', $s2); + $this->assertEquals('It is me again. Hello :a', $s3); + $this->assertEquals('Hello :a and good night', $s4); + } + + /** + * + * @expectedException \Exception + */ + /* + * public function testToStringException1() + * { + * $e = new MyBadExpression('Hello'); + * $s = (string)$e; + * } + */ + + /** + * expr() should return new Expression object and inherit connection from it. + */ + public function testExpr() + { + $e = $this->e([ + 'connection' => new \stdClass() + ]); + $this->assertEquals(true, $e->expr()->connection instanceof \stdClass); + } + + /** + * Fully covers _escape method. + */ + public function testEscape() + { + // escaping expressions + $this->assertEquals('"first_name"', $this->callProtected($this->e(), '_escape', [ + 'first_name' + ])); + $this->assertEquals('"123"', $this->callProtected($this->e(), '_escape', [ + 123 + ])); + $this->assertEquals('"he""llo"', $this->callProtected($this->e(), '_escape', [ + 'he"llo' + ])); + + // should not escape expressions + $this->assertEquals('*', $this->callProtected($this->e(), '_escapeSoft', [ + '*' + ])); + $this->assertEquals('"*"', $this->callProtected($this->e(), '_escape', [ + '*' + ])); + $this->assertEquals('(2+2) age', $this->callProtected($this->e(), '_escapeSoft', [ + '(2+2) age' + ])); + $this->assertEquals('"(2+2) age"', $this->callProtected($this->e(), '_escape', [ + '(2+2) age' + ])); + $this->assertEquals('"users"."first_name"', $this->callProtected($this->e(), '_escapeSoft', [ + 'users.first_name' + ])); + $this->assertEquals('"users".*', $this->callProtected($this->e(), '_escapeSoft', [ + 'users.*' + ])); + $this->assertEquals(true, $this->callProtected($this->e(), '_escapeSoft', [ + new \stdClass() + ]) instanceof \stdClass); + + // escaping array - escapes each of its elements using hard escape + $this->assertEquals([ + '"first_name"', + '*', + '"last_name"' + ], $this->callProtected($this->e(), '_escapeSoft', [ + [ + 'first_name', + '*', + 'last_name' + ] + ])); + + // escaping array - escapes each of its elements using hard escape + $this->assertEquals([ + '"first_name"', + '"*"', + '"last_name"' + ], $this->callProtected($this->e(), '_escape', [ + [ + 'first_name', + '*', + 'last_name' + ] + ])); + + $this->assertEquals('"first_name"', $this->e() + ->escape('first_name') + ->render()); + $this->assertEquals('"first""_name"', $this->e() + ->escape('first"_name') + ->render()); + $this->assertEquals('"first""_name {}"', $this->e() + ->escape('first"_name {}') + ->render()); + } + + /** + * Fully covers _param method. + */ + public function testParam() + { + $e = new Expression('hello, [who]', [ + 'who' => 'world' + ]); + $this->assertEquals('hello, :a', $e->render()); + $this->assertEquals([ + ':a' => 'world' + ], $e->params); + + // @todo Imants: allowing to pass value as array looks wrong. + // See test case in testParam() method. + // Maybe we should add implode(' ', array_map(...)) here ? + $e = new Expression('hello, [who]', [ + 'who' => [ + 'cruel', + 'world' + ] + ]); + $this->assertEquals('hello, (:a,:b)', $e->render()); + $this->assertEquals([ + ':a' => 'cruel', + ':b' => 'world' + ], $e->params); + } + + /** + * + * @test + */ + public function testConsume() + { + // few brief tests on _consume + $this->assertEquals('"123"', $this->callProtected($this->e(), '_consume', [ + 123, + 'escape' + ])); + $this->assertEquals(':x', $this->callProtected($this->e([ + '_paramBase' => 'x' + ]), '_consume', [ + 123, + 'param' + ])); + $this->assertEquals(123, $this->callProtected($this->e(), '_consume', [ + 123, + 'none' + ])); + $this->assertEquals('(select *)', $this->callProtected($this->e(), '_consume', [ + new Query() + ])); + + $this->assertEquals('hello, "myfield"', $this->e('hello, []', [ + new MyField() + ]) + ->render()); + } + + /** + * $escape_mode value is incorrect. + * + * @expectedException \Pluf\Db\Exception + */ + public function testConsumeException1() + { + $this->callProtected($this->e(), '_consume', [ + 123, + 'blahblah' + ]); + } + + /** + * Only Expressions or Expressionable objects may be used in Expression. + * + * @expectedException \Pluf\Db\Exception + */ + public function testConsumeException2() + { + $this->callProtected($this->e(), '_consume', [ + new \StdClass() + ]); + } + + /** + * + * @test + */ + public function testArrayAccess() + { + $e = $this->e('', [ + 'parrot' => 'red', + 'blue' + ]); + + // offsetGet + $this->assertEquals('red', $e['parrot']); + $this->assertEquals('blue', $e[0]); + + // offsetSet + $e['cat'] = 'black'; + $this->assertEquals('black', $e['cat']); + $e['cat'] = 'white'; + $this->assertEquals('white', $e['cat']); + + // offsetExists, offsetUnset + $this->assertEquals(true, isset($e['cat'])); + unset($e['cat']); + $this->assertEquals(false, isset($e['cat'])); + + // testing absence of specific key in asignment + $e = $this->e('[], []'); + $e[] = 'Hello'; + $e[] = 'World'; + $this->assertEquals("'Hello', 'World'", strip_tags($e->getDebugQuery())); + + // real-life example + $age = $this->e('coalesce([age], [default_age])'); + $age['age'] = $this->e('year(now()) - year(birth_date)'); + $age['default_age'] = 18; + $this->assertEquals('coalesce(year(now()) - year(birth_date), :a)', $age->render()); + } + + /** + * Test IteratorAggregate implementation. + */ + public function testIteratorAggregate() + { + // todo - can not test this without actual DB connection and executing expression + null; + } + + /** + * Test for vendors that rely on JavaScript expressions, instead of parameters. + * + * @coversNothing + */ + public function testJsonExpression() + { + $e = new JsonExpression('hello, [who]', [ + 'who' => 'world' + ]); + + $this->assertEquals('hello, "world"', $e->render()); + $this->assertEquals([], $e->params); + } + + /** + * Test reset exception if tag is not a string. + * + * @expectedException \Pluf\Db\Exception + */ + public function testResetException() + { + $this->e('test')->reset($this->e()); + } + + /** + * Test var-dump code for codecoverage. + */ + public function testVarDump() + { + $this->e('test')->__debugInfo(); + + $this->e(' [nosuchtag] ')->__debugInfo(); + } + + /** + * Test reset(). + */ + public function testReset() + { + // reset everything + $e = $this->e('hello, [name] [surname]', [ + 'name' => 'John', + 'surname' => 'Doe' + ]); + $e->reset(); + $this->assertAttributeEquals([ + 'custom' => [] + ], 'args', $e); + + // reset particular custom/tag + $e = $this->e('hello, [name] [surname]', [ + 'name' => 'John', + 'surname' => 'Doe' + ]); + $e->reset('surname'); + $this->assertAttributeEquals([ + 'custom' => [ + 'name' => 'John' + ] + ], 'args', $e); + } +} + +// @codingStandardsIgnoreStart +class JsonExpression extends Expression +{ + + public function _param($value) + { + return json_encode($value); + } +} + +class MyField implements Expressionable +{ + + public function getDSQLExpression($e) + { + return $e->expr('"myfield"'); + } +} +/* +class MyBadExpression extends Expression +{ + public function getOne() + { + // should return string, but for test case we return array to get \Exception + return array(); + } +} +*/ +// @codingStandardsIgnoreEnd diff --git a/tests/Db/OracleTest.php b/tests/Db/OracleTest.php new file mode 100644 index 00000000..e034f70b --- /dev/null +++ b/tests/Db/OracleTest.php @@ -0,0 +1,128 @@ +assertEquals('select "baz" from "foo" where "bar" = :a', $c->dsql() + ->table('foo') + ->where('bar', 1) + ->field('baz') + ->render()); + } catch (\PDOException $e) { + if (! extension_loaded('oci8')) { + $this->markTestSkipped('The oci8 extension is not available.'); + } + + throw $e; + } + } + + public function connect($ver = '') + { + return new Connection(array_merge([ + 'connection' => new \PDO('sqlite::memory:'), + 'query_class' => 'Pluf\Db\Query\Oracle' . $ver, + 'expression_class' => 'atk4\dsql\Expression_Oracle' + ])); + } + + public function testOracleClass() + { + $c = $this->connect(); + $this->assertEquals('select "baz" from "foo" where "bar" = :a', $c->dsql() + ->table('foo') + ->where('bar', 1) + ->field('baz') + ->render()); + + $this->assertEquals('select "baz" "ali" from "foo" where "bar" = :a', $c->dsql() + ->table('foo') + ->where('bar', 1) + ->field('baz', 'ali') + ->render()); + } + + public function testClassicOracleLimit() + { + $c = $this->connect(); + $this->assertEquals('select * from (select rownum "__dsql_rownum","__t".* from (select "baz" from "foo" where "bar" = :a) "__t") where "__dsql_rownum">0 and "__dsql_rownum"<=10', $c->dsql() + ->table('foo') + ->where('bar', 1) + ->field('baz') + ->limit(10) + ->render()); + + $this->assertEquals('select * from (select rownum "__dsql_rownum","__t".* from (select "baz" "baz_alias" from "foo" where "bar" = :a) "__t") where "__dsql_rownum">0 and "__dsql_rownum"<=10', $c->dsql() + ->table('foo') + ->where('bar', 1) + ->field('baz', 'baz_alias') + ->limit(10) + ->render()); + } + + public function test12cOracleLimit() + { + $c = $this->connect('12c'); + $this->assertEquals('select "baz" from "foo" where "bar" = :a FETCH NEXT 10 ROWS ONLY', $c->dsql() + ->table('foo') + ->where('bar', 1) + ->field('baz') + ->limit(10) + ->render()); + } + + public function testClassicOracleSkip() + { + $c = $this->connect(); + $this->assertEquals('select * from (select rownum "__dsql_rownum","__t".* from (select "baz" from "foo" where "bar" = :a) "__t") where "__dsql_rownum">10', $c->dsql() + ->table('foo') + ->where('bar', 1) + ->field('baz') + ->limit(null, 10) + ->render()); + } + + public function test12cOracleSkip() + { + $c = $this->connect('12c'); + $this->assertEquals('select "baz" from "foo" where "bar" = :a OFFSET 10 ROWS', $c->dsql() + ->table('foo') + ->where('bar', 1) + ->field('baz') + ->limit(null, 10) + ->render()); + } + + public function testClassicOracleLimitSkip() + { + $c = $this->connect(); + $this->assertEquals('select * from (select rownum "__dsql_rownum","__t".* from (select "baz" from "foo" where "bar" = :a) "__t") where "__dsql_rownum">99 and "__dsql_rownum"<=109', $c->dsql() + ->table('foo') + ->where('bar', 1) + ->field('baz') + ->limit(10, 99) + ->render()); + } + + public function test12cOracleLimitSkip() + { + $c = $this->connect('12c'); + $this->assertEquals('select "baz" from "foo" where "bar" = :a OFFSET 99 ROWS FETCH NEXT 10 ROWS ONLY', $c->dsql() + ->table('foo') + ->where('bar', 1) + ->field('baz') + ->limit(10, 99) + ->render()); + } +} diff --git a/tests/Db/QueryTest.php b/tests/Db/QueryTest.php new file mode 100644 index 00000000..fc6440ba --- /dev/null +++ b/tests/Db/QueryTest.php @@ -0,0 +1,1612 @@ +assertEquals('"q"', $this->callProtected($this->q(), '_escape', [ + 'q' + ])); + } + + /** + * dsql() should return new Query object and inherit connection from it. + */ + public function testDsql() + { + $q = $this->q([ + 'connection' => new \stdClass() + ]); + $this->assertEquals(true, $q->dsql()->connection instanceof \stdClass); + } + + /** + * field() should return $this Query for chaining. + */ + public function testFieldReturnValue() + { + $q = $this->q(); + $this->assertEquals($q, $q->field('first_name')); + } + + /** + * Testing field - basic cases. + */ + public function testFieldBasic() + { + $this->assertEquals('"first_name"', $this->callProtected($this->q() + ->field('first_name'), '_render_field')); + $this->assertEquals('"first_name","last_name"', $this->callProtected($this->q() + ->field('first_name,last_name'), '_render_field')); + $this->assertEquals('"first_name","last_name"', $this->callProtected($this->q() + ->field('first_name') + ->field('last_name'), '_render_field')); + $this->assertEquals('"last_name"', $this->callProtected($this->q() + ->field('first_name') + ->reset('field') + ->field('last_name'), '_render_field')); + $this->assertEquals('*', $this->callProtected($this->q() + ->field('first_name') + ->reset('field'), '_render_field')); + $this->assertEquals('*', $this->callProtected($this->q() + ->field('first_name') + ->reset(), '_render_field')); + $this->assertEquals('"employee"."first_name"', $this->callProtected($this->q() + ->field('employee.first_name'), '_render_field')); + $this->assertEquals('"first_name" "name"', $this->callProtected($this->q() + ->field('first_name', 'name'), '_render_field')); + $this->assertEquals('"first_name" "name"', $this->callProtected($this->q() + ->field([ + 'name' => 'first_name' + ]), '_render_field')); + $this->assertEquals('"name"', $this->callProtected($this->q() + ->field([ + 'name' => 'name' + ]), '_render_field')); + $this->assertEquals('"employee"."first_name" "name"', $this->callProtected($this->q() + ->field([ + 'name' => 'employee.first_name' + ]), '_render_field')); + $this->assertEquals('*', $this->callProtected($this->q() + ->field('*'), '_render_field')); + $this->assertEquals('"employee"."first_name"', $this->callProtected($this->q() + ->field('employee.first_name'), '_render_field')); + } + + /** + * Testing field - defaultField. + */ + public function testFieldDefaultField() + { + // default defaultField + $this->assertEquals('*', $this->callProtected($this->q(), '_render_field')); + // defaultField as custom string - not escaped + $this->assertEquals('id', $this->callProtected($this->q([ + 'defaultField' => 'id' + ]), '_render_field')); + // defaultField as custom string with dot - not escaped + $this->assertEquals('all.values', $this->callProtected($this->q([ + 'defaultField' => 'all.values' + ]), '_render_field')); + // defaultField as Expression object - not escaped + $this->assertEquals('values()', $this->callProtected($this->q([ + 'defaultField' => new Expression('values()') + ]), '_render_field')); + } + + /** + * Testing field - basic cases. + */ + public function testFieldExpression() + { + $this->assertEquals('"name"', $this->q('[field]') + ->field('name') + ->render()); + $this->assertEquals('"first name"', $this->q('[field]') + ->field('first name') + ->render()); + $this->assertEquals('"first"."name"', $this->q('[field]') + ->field('first.name') + ->render()); + $this->assertEquals('now()', $this->q('[field]') + ->field('now()') + ->render()); + $this->assertEquals('now()', $this->q('[field]') + ->field(new Expression('now()')) + ->render()); + // Usage of field aliases + $this->assertEquals('now() "time"', $this->q('[field]') + ->field('now()', 'time') + ->render()); + $this->assertEquals( // alias can be passed as 2nd argument + 'now() "time"', $this->q('[field]') + ->field(new Expression('now()'), 'time') + ->render()); + $this->assertEquals( // alias can be passed as 3nd argument + 'now() "time"', $this->q('[field]') + ->field([ + 'time' => new Expression('now()') + ]) + ->render()); + } + + /** + * Duplicate alias of field. + * + * @expectedException Pluf\Exception + */ + public function testFieldException1() + { + $this->q() + ->field('name', 'a') + ->field('surname', 'a'); + } + + /** + * There shouldn't be alias when passing fields as array. + * + * @expectedException Exception + */ + public function testFieldException2() + { + $this->q()->field([ + 'name', + 'surname' + ], 'a'); + } + + /** + * There shouldn't be alias when passing multiple tables. + * + * @expectedException Pluf\Db\Exception + */ + public function testTableException1() + { + $this->q()->table('employee,jobs', 'u'); + } + + /** + * There shouldn't be alias when passing multiple tables. + * + * @expectedException Pluf\Db\Exception + */ + public function testTableException2() + { + $this->q()->table([ + 'employee', + 'jobs' + ], 'u'); + } + + /** + * Alias is NOT mandatory when pass table as Expression. + */ + public function testTableException3() + { + $this->q()->table($this->q() + ->expr('test')); + } + + /** + * Alias is IS mandatory when pass table as Query. + * + * @expectedException Pluf\Db\Exception + */ + public function testTableException4() + { + $this->q()->table($this->q() + ->table('test')); + } + + /** + * Table aliases should be unique. + * + * @expectedException Pluf\Db\Exception + */ + public function testTableException5() + { + $this->q() + ->table('foo', 'a') + ->table('bar', 'a'); + } + + /** + * Table aliases should be unique. + * + * @expectedException Pluf\Db\Exception + */ + public function testTableException6() + { + $this->q() + ->table('foo', 'bar') + ->table('bar'); + } + + /** + * Table aliases should be unique. + * + * @expectedException Pluf\Db\Exception + */ + public function testTableException7() + { + $this->q() + ->table('foo') + ->table('foo'); + } + + /** + * Table aliases should be unique. + * + * @expectedException Pluf\Db\Exception + */ + public function testTableException8() + { + $this->q() + ->table($this->q() + ->table('test'), 'foo') + ->table('foo'); + } + + /** + * Table aliases should be unique. + * + * @expectedException Pluf\Db\Exception + */ + public function testTableException9() + { + $this->q() + ->table('foo') + ->table($this->q() + ->table('test'), 'foo'); + } + + /** + * Table can't be set as sub-Query in Update query mode. + * + * @expectedException Pluf\Db\Exception + */ + public function testTableException10() + { + $this->q() + ->mode('update') + ->table($this->q() + ->table('test'), 'foo') + ->field('name') + ->set('name', 1) + ->render(); + } + + /** + * Table can't be set as sub-Query in Insert query mode. + * + * @expectedException Pluf\Db\Exception + */ + public function testTableException11() + { + $this->q() + ->mode('insert') + ->table($this->q() + ->table('test'), 'foo') + ->field('name') + ->set('name', 1) + ->render(); + } + + /** + * Requesting non-existant query mode should throw exception. + * + * @expectedException Pluf\Db\Exception + */ + public function testModeException1() + { + $this->q()->mode('non_existant_mode'); + } + + /** + * table() should return $this Query for chaining. + */ + public function testTableReturnValue() + { + $q = $this->q(); + $this->assertEquals($q, $q->table('employee')); + } + + /** + */ + public function testTableRender1() + { + // no table defined + $this->assertEquals('select now()', $this->q() + ->field(new Expression('now()')) + ->render()); + + // one table + $this->assertEquals('select "name" from "employee"', $this->q() + ->field('name') + ->table('employee') + ->render()); + + $this->assertEquals('select "na#me" from "employee"', $this->q() + ->field('"na#me"') + ->table('employee') + ->render()); + $this->assertEquals('select "na""me" from "employee"', $this->q() + ->field(new Expression('{}', [ + 'na"me' + ])) + ->table('employee') + ->render()); + $this->assertEquals('select "жук" from "employee"', $this->q() + ->field(new Expression('{}', [ + 'жук' + ])) + ->table('employee') + ->render()); + $this->assertEquals('select "this is 💩" from "employee"', $this->q() + ->field(new Expression('{}', [ + 'this is 💩' + ])) + ->table('employee') + ->render()); + + $this->assertEquals('select "name" from "employee" "e"', $this->q() + ->field('name') + ->table('employee', 'e') + ->render()); + $this->assertEquals('select * from "employee" "e"', $this->q() + ->table('employee', 'e') + ->render()); + + // multiple tables + $this->assertEquals('select "employee"."name" from "employee","jobs"', $this->q() + ->field('employee.name') + ->table('employee') + ->table('jobs') + ->render()); + $this->assertEquals('select "name" from "employee","jobs"', $this->q() + ->field('name') + ->table('employee,jobs') + ->render()); + $this->assertEquals('select "name" from "employee","jobs"', $this->q() + ->field('name') + ->table(' employee , jobs ') + ->render()); + $this->assertEquals('select "name" from "employee","jobs"', $this->q() + ->field('name') + ->table([ + 'employee', + 'jobs' + ]) + ->render()); + $this->assertEquals('select "name" from "employee","jobs"', $this->q() + ->field('name') + ->table([ + 'employee ', + ' jobs' + ]) + ->render()); + + // multiple tables with aliases + $this->assertEquals('select "name" from "employee","jobs" "j"', $this->q() + ->field('name') + ->table([ + 'employee', + 'j' => 'jobs' + ]) + ->render()); + $this->assertEquals('select "name" from "employee" "e","jobs" "j"', $this->q() + ->field('name') + ->table([ + 'e' => 'employee', + 'j' => 'jobs' + ]) + ->render()); + // testing _render_table_noalias, shouldn't render table alias 'emp' + $this->assertEquals('insert into "employee" ("name") values (:a)', $this->q() + ->field('name') + ->table('employee', 'emp') + ->set('name', 1) + ->mode('insert') + ->render()); + $this->assertEquals('update "employee" set "name"=:a', $this->q() + ->field('name') + ->table('employee', 'emp') + ->set('name', 1) + ->mode('update') + ->render()); + } + + /** + */ + public function testTableRender2() + { + // pass table as expression or query + $q = $this->q()->table('employee'); + + $this->assertEquals('select "name" from (select * from "employee") "e"', $this->q() + ->field('name') + ->table($q, 'e') + ->render()); + + $this->assertEquals('select "name" from "myt""able"', $this->q() + ->field('name') + ->table(new Expression('{}', [ + 'myt"able' + ])) + ->render()); + + // test with multiple sub-queries as tables + $q1 = $this->q()->table('employee'); + $q2 = $this->q()->table('customer'); + + $this->assertEquals( + // this way it would be more correct: 'select "e"."name","c"."name" from (select * from "employee") "e",(select * from "customer") "c" where "e"."last_name" = "c"."last_name"', + 'select "e"."name","c"."name" from (select * from "employee") "e",(select * from "customer") "c" where "e"."last_name" = c.last_name', $this->q() + ->field('e.name') + ->field('c.name') + ->table($q1, 'e') + ->table($q2, 'c') + ->where('e.last_name', $this->q() + ->expr('c.last_name')) + ->render()); + } + + /** + * + * @test + */ + public function testBasicRenderSubquery() + { + $age = new Expression('coalesce([age], [default_age])'); + $age['age'] = new Expression('year(now()) - year(birth_date)'); + $age['default_age'] = 18; + + $q = $this->q() + ->table('user') + ->field($age, 'calculated_age'); + + $this->assertEquals('select coalesce(year(now()) - year(birth_date), :a) "calculated_age" from "user"', $q->render()); + } + + /** + * + * @test + */ + public function testgetDebugQuery() + { + $age = new Expression('coalesce([age], [default_age], [foo], [bar])'); + $age['age'] = new Expression('year(now()) - year(birth_date)'); + $age['default_age'] = 18; + $age['foo'] = 'foo'; + $age['bar'] = null; + + $q = $this->q() + ->table('user') + ->field($age, 'calculated_age'); + + $this->assertEquals("select coalesce(year(now()) - year(birth_date), 18, 'foo', NULL) \"calculated_age\" from \"user\"", strip_tags($q->getDebugQuery())); + } + + /** + * + * @requires PHP 5.6 + */ + public function testVarDump() + { + ini_set('xdebug.overload_var_dump', 'off'); + $this->expectOutputRegex('/.*select \* from "user".*/'); + var_dump($this->q()->table('user')); + } + +// public function testVarDump2() +// { +// ini_set('xdebug.overload_var_dump', 'off'); +// $this->expectOutputRegex('/.*Expression could not render tag.*/'); +// var_dump(new Expression('Hello [world]')); +// } + + public function testVarDump3() + { + ini_set('xdebug.overload_var_dump', 'off'); + $this->expectOutputRegex('/.*Hello \'php\'.*/'); + var_dump(new Expression('Hello [world]', [ + 'world' => 'php' + ])); + } + +// /** +// * +// * @requires PHP 5.6 +// */ +// public function testVarDump4() +// { +// ini_set('xdebug.overload_var_dump', 'off'); +// $this->expectOutputRegex('/.*Table cannot be Query.*/'); +// // should throw exception "Table cannot be Query in UPDATE, INSERT etc. query modes" +// var_dump($this->q() +// ->mode('update') +// ->table($this->q() +// ->table('test'), 'foo')); +// } + + /** + * + * @test + */ + public function testUnionQuery() + { + // 1st query + $q1 = $this->q() + ->table('sales') + ->field('date') + ->field('amount', 'debit') + ->field($this->q() + ->expr('0'), 'credit'); // simply 0 + + $this->assertEquals('select "date","amount" "debit",0 "credit" from "sales"', $q1->render()); + + // 2nd query + $q2 = $this->q() + ->table('purchases') + ->field('date') + ->field($this->q() + ->expr('0'), 'debit') + -> + // simply 0 + field('amount', 'credit'); + $this->assertEquals('select "date",0 "debit","amount" "credit" from "purchases"', $q2->render()); + + // $q1 union $q2 + $u = new Expression('[] union []', [ + $q1, + $q2 + ]); + $this->assertEquals('(select "date","amount" "debit",0 "credit" from "sales") union (select "date",0 "debit","amount" "credit" from "purchases")', $u->render()); + + // SELECT date,debit,credit FROM ($q1 union $q2) + $q = $this->q() + ->field('date,debit,credit') + ->table($u, 'derrivedTable'); + /* + * @see https://github.com/atk4/dsql/issues/33 + * @see https://github.com/atk4/dsql/issues/34 + */ + /* + * $this->assertEquals( + * 'select "date","debit","credit" from ((select "date","amount" "debit",0 "credit" from "sales") union (select "date",0 "debit","amount" "credit" from "purchases")) "derrivedTable"', + * $q->render() + * ); + */ + } + + /** + * where() should return $this Query for chaining. + */ + public function testWhereReturnValue() + { + $q = $this->q(); + $this->assertEquals($q, $q->where('id', 1)); + } + + /** + * having() should return $this Query for chaining. + */ + public function testHavingReturnValue() + { + $q = $this->q(); + $this->assertEquals($q, $q->having('id', 1)); + } + + /** + * Basic where() tests. + */ + public function testWhereBasic() + { + // one parameter as a string - treat as expression + $this->assertEquals('where now()', $this->q('[where]') + ->where('now()') + ->render()); + $this->assertEquals('where foo >= bar', $this->q('[where]') + ->where('foo >= bar') + ->render()); + + // two parameters - field, value + $this->assertEquals('where "id" = :a', $this->q('[where]') + ->where('id', 1) + ->render()); + $this->assertEquals('where "user"."id" = :a', $this->q('[where]') + ->where('user.id', 1) + ->render()); + $this->assertEquals('where "db"."user"."id" = :a', $this->q('[where]') + ->where('db.user.id', 1) + ->render()); + $this->assertEquals('where "id" is :a', $this->q('[where]') + ->where('id', null) + ->render()); + $this->assertEquals('where "id" is :a', $this->q('[where]') + ->where('id', null) + ->render()); + + // three parameters - field, condition, value + $this->assertEquals('where "id" > :a', $this->q('[where]') + ->where('id', '>', 1) + ->render()); + $this->assertEquals('where "id" < :a', $this->q('[where]') + ->where('id', '<', 1) + ->render()); + $this->assertEquals('where "id" = :a', $this->q('[where]') + ->where('id', '=', 1) + ->render()); + $this->assertEquals('where "id" in (:a,:b)', $this->q('[where]') + ->where('id', '=', [ + 1, + 2 + ]) + ->render()); + $this->assertEquals('where "id" in (:a,:b)', $this->q('[where]') + ->where('id', [ + 1, + 2 + ]) + ->render()); + $this->assertEquals('where "id" in (select * from "user")', $this->q('[where]') + ->where('id', $this->q() + ->table('user')) + ->render()); + + // two parameters - more_than_just_a_field, value + $this->assertEquals('where "id" = :a', $this->q('[where]') + ->where('id=', 1) + ->render()); + $this->assertEquals('where "id" != :a', $this->q('[where]') + ->where('id!=', 1) + ->render()); + $this->assertEquals('where "id" <> :a', $this->q('[where]') + ->where('id<>', 1) + ->render()); + + // field name with special symbols - not escape + $this->assertEquals('where now() = :a', $this->q('[where]') + ->where('now()', 1) + ->render()); + + // field name as expression + $this->assertEquals('where now = :a', $this->q('[where]') + ->where(new Expression('now'), 1) + ->render()); + + // more than one where condition - join with AND keyword + $this->assertEquals('where "a" = :a and "b" is :b', $this->q('[where]') + ->where('a', 1) + ->where('b', null) + ->render()); + } + + /** + * Verify that passing garbage to where throw exception. + * + * @expectedException Exception + */ + public function testWhereIncompatibleObject1() + { + $this->q('[where]') + ->where('a', new \DateTime()) + ->render(); + } + + /** + * Verify that passing garbage to where throw exception. + * + * @expectedException Exception + */ + public function testWhereIncompatibleObject2() + { + $this->q('[where]')->where('a', new \DateTime()); + } + + /** + * Verify that passing garbage to where throw exception. + * + * @expectedException Exception + */ + public function testWhereIncompatibleObject3() + { + $this->q('[where]')->where('a', '<>', new \DateTime()); + } + + /** + * Testing where() with special values - null, array, like. + */ + public function testWhereSpecialValues() + { + // in | not in + $this->assertEquals('where "id" in (:a,:b)', $this->q('[where]') + ->where('id', 'in', [ + 1, + 2 + ]) + ->render()); + $this->assertEquals('where "id" not in (:a,:b)', $this->q('[where]') + ->where('id', 'not in', [ + 1, + 2 + ]) + ->render()); + $this->assertEquals('where "id" not in (:a,:b)', $this->q('[where]') + ->where('id', 'not', [ + 1, + 2 + ]) + ->render()); + $this->assertEquals('where "id" in (:a,:b)', $this->q('[where]') + ->where('id', '=', [ + 1, + 2 + ]) + ->render()); + $this->assertEquals('where "id" not in (:a,:b)', $this->q('[where]') + ->where('id', '<>', [ + 1, + 2 + ]) + ->render()); + $this->assertEquals('where "id" not in (:a,:b)', $this->q('[where]') + ->where('id', '!=', [ + 1, + 2 + ]) + ->render()); + // speacial treatment for empty array values + $this->assertEquals('where "id"<>"id"', $this->q('[where]') + ->where('id', '=', []) + ->render()); + $this->assertEquals('where ("id"="id" or "id" is null)', $this->q('[where]') + ->where('id', '<>', []) + ->render()); + // pass array as CSV + $this->assertEquals('where "id" in (:a,:b)', $this->q('[where]') + ->where('id', 'in', '1,2') + ->render()); + $this->assertEquals('where "id" not in (:a,:b)', $this->q('[where]') + ->where('id', 'not in', '1, 2') + ->render()); + $this->assertEquals('where "id" not in (:a,:b)', $this->q('[where]') + ->where('id', 'not', '1,2') + ->render()); + + // is | is not + $this->assertEquals('where "id" is :a', $this->q('[where]') + ->where('id', 'is', null) + ->render()); + $this->assertEquals('where "id" is not :a', $this->q('[where]') + ->where('id', 'is not', null) + ->render()); + $this->assertEquals('where "id" is not :a', $this->q('[where]') + ->where('id', 'not', null) + ->render()); + $this->assertEquals('where "id" is :a', $this->q('[where]') + ->where('id', '=', null) + ->render()); + $this->assertEquals('where "id" is not :a', $this->q('[where]') + ->where('id', '<>', null) + ->render()); + $this->assertEquals('where "id" is not :a', $this->q('[where]') + ->where('id', '!=', null) + ->render()); + + // like | not like + $this->assertEquals('where "name" like :a', $this->q('[where]') + ->where('name', 'like', 'foo') + ->render()); + $this->assertEquals('where "name" not like :a', $this->q('[where]') + ->where('name', 'not like', 'foo') + ->render()); + + // two parameters - more_than_just_a_field, value + // is | is not + $this->assertEquals('where "id" is :a', $this->q('[where]') + ->where('id=', null) + ->render()); + $this->assertEquals('where "id" is not :a', $this->q('[where]') + ->where('id!=', null) + ->render()); + $this->assertEquals('where "id" is not :a', $this->q('[where]') + ->where('id<>', null) + ->render()); + + // in | not in + $this->assertEquals('where "id" in (:a,:b)', $this->q('[where]') + ->where('id=', [ + 1, + 2 + ]) + ->render()); + $this->assertEquals('where "id" not in (:a,:b)', $this->q('[where]') + ->where('id!=', [ + 1, + 2 + ]) + ->render()); + $this->assertEquals('where "id" not in (:a,:b)', $this->q('[where]') + ->where('id<>', [ + 1, + 2 + ]) + ->render()); + } + + /** + * Having basically is the same as where, so we can relax and trouhly test where() instead. + */ + public function testBasicHaving() + { + $this->assertEquals('having "id" = :a', $this->q('[having]') + ->having('id', 1) + ->render()); + $this->assertEquals('having "id" > :a', $this->q('[having]') + ->having('id', '>', 1) + ->render()); + $this->assertEquals('where "id" = :a having "id" > :b', $this->q('[where][having]') + ->where('id', 1) + ->having('id>', 1) + ->render()); + } + + /** + * Test Limit. + */ + public function testLimit() + { + $this->assertEquals('limit 0, 100', $this->q('[limit]') + ->limit(100) + ->render()); + $this->assertEquals('limit 200, 100', $this->q('[limit]') + ->limit(100, 200) + ->render()); + } + + /** + * Test Order. + */ + public function testOrder() + { + $this->assertEquals('order by "name"', $this->q('[order]') + ->order('name') + ->render()); + $this->assertEquals('order by "name", "surname"', $this->q('[order]') + ->order('name,surname') + ->render()); + $this->assertEquals('order by "name" desc, "surname" desc', $this->q('[order]') + ->order('name desc,surname desc') + ->render()); + $this->assertEquals('order by "name" desc, "surname"', $this->q('[order]') + ->order([ + 'name desc', + 'surname' + ]) + ->render()); + $this->assertEquals('order by "name" desc, "surname"', $this->q('[order]') + ->order('surname') + ->order('name desc') + ->render()); + $this->assertEquals('order by "name" desc, "surname"', $this->q('[order]') + ->order('surname', false) + ->order('name', true) + ->render()); + // table name|alias included + $this->assertEquals('order by "users"."name"', $this->q('[order]') + ->order('users.name') + ->render()); + // strange field names + $this->assertEquals('order by "my name" desc', $this->q('[order]') + ->order('"my name" desc') + ->render()); + $this->assertEquals('order by "жук"', $this->q('[order]') + ->order('жук asc') + ->render()); + $this->assertEquals('order by "this is 💩"', $this->q('[order]') + ->order('this is 💩') + ->render()); + $this->assertEquals('order by "this is жук" desc', $this->q('[order]') + ->order('this is жук desc') + ->render()); + $this->assertEquals('order by * desc', $this->q('[order]') + ->order([ + '* desc' + ]) + ->render()); + $this->assertEquals('order by "{}" desc', $this->q('[order]') + ->order([ + '{} desc' + ]) + ->render()); + $this->assertEquals('order by "* desc"', $this->q('[order]') + ->order(new Expression('"* desc"')) + ->render()); + $this->assertEquals('order by "* desc"', $this->q('[order]') + ->order($this->q() + ->escape('* desc')) + ->render()); + $this->assertEquals('order by "* desc {}"', $this->q('[order]') + ->order($this->q() + ->escape('* desc {}')) + ->render()); + // custom sort order + $this->assertEquals('order by "name" desc nulls last', $this->q('[order]') + ->order('name', 'desc nulls last') + ->render()); + $this->assertEquals('order by "name" nulls last', $this->q('[order]') + ->order('name', 'nulls last') + ->render()); + } + + /** + * If first argument is array, second argument must not be used. + * + * @expectedException Exception + */ + public function testOrderException1() + { + $this->q('[order]')->order([ + 'name', + 'surname' + ], 'desc'); + } + + /** + * Test Group. + */ + public function testGroup() + { + $this->assertEquals('group by "gender"', $this->q('[group]') + ->group('gender') + ->render()); + $this->assertEquals('group by "gender", "age"', $this->q('[group]') + ->group('gender,age') + ->render()); + $this->assertEquals('group by "gender", "age"', $this->q('[group]') + ->group([ + 'gender', + 'age' + ]) + ->render()); + $this->assertEquals('group by "gender", "age"', $this->q('[group]') + ->group('gender') + ->group('age') + ->render()); + // table name|alias included + $this->assertEquals('group by "users"."gender"', $this->q('[group]') + ->group('users.gender') + ->render()); + // strange field names + $this->assertEquals('group by "my name"', $this->q('[group]') + ->group('"my name"') + ->render()); + $this->assertEquals('group by "жук"', $this->q('[group]') + ->group('жук') + ->render()); + $this->assertEquals('group by "this is 💩"', $this->q('[group]') + ->group('this is 💩') + ->render()); + $this->assertEquals('group by "this is жук"', $this->q('[group]') + ->group('this is жук') + ->render()); + $this->assertEquals('group by date_format(dat, "%Y")', $this->q('[group]') + ->group(new Expression('date_format(dat, "%Y")')) + ->render()); + $this->assertEquals('group by date_format(dat, "%Y")', $this->q('[group]') + ->group('date_format(dat, "%Y")') + ->render()); + } + + /** + * Test groupConcat. + * + * @expectedException Exception + */ + public function testGroupConcatException() + { + // doesn't support groupConcat by default + $this->q()->groupConcat('foo'); + } + + /** + * Test groupConcat. + */ + public function testGroupConcat() + { + $q = new Query\MySQL(); + $this->assertEquals('group_concat(`foo` separator :a)', $q->groupConcat('foo', '-') + ->render()); + + $q = new Query\Oracle(); + $this->assertEquals('listagg("foo", :a)', $q->groupConcat('foo', '-') + ->render()); + + $q = new Query\Oracle12c(); + $this->assertEquals('listagg("foo", :a)', $q->groupConcat('foo', '-') + ->render()); + + $q = new Query\PgSQL(); + $this->assertEquals('string_agg("foo", :a)', $q->groupConcat('foo', '-') + ->render()); + + $q = new Query\SQLite(); + $this->assertEquals('group_concat("foo", :a)', $q->groupConcat('foo', '-') + ->render()); + } + + /** + * Test expr(). + */ + public function testExpr() + { + $this->assertEquals('Pluf\\Db\\Expression', get_class($this->q() + ->expr('foo'))); + + $q = new Query\MySQL(); + $this->assertEquals('Pluf\\Db\\Expression\\MySQL', get_class($q->expr('foo'))); + } + + /** + * Test Join. + */ + public function testJoin() + { + $this->assertEquals('left join "address" on "address"."id" = "address_id"', $this->q('[join]') + ->join('address') + ->render()); + $this->assertEquals('left join "address" as "a" on "a"."id" = "address_id"', $this->q('[join]') + ->join('address a') + ->render()); + $this->assertEquals('left join "address" as "a" on "a"."id" = "user"."address_id"', $this->q('[join]') + ->table('user') + ->join('address a') + ->render()); + $this->assertEquals('left join "address" as "a" on "a"."id" = "user"."my_address_id"', $this->q('[join]') + ->table('user') + ->join('address a', 'my_address_id') + ->render()); + $this->assertEquals('left join "address" as "a" on "a"."id" = "u"."address_id"', $this->q('[join]') + ->table('user', 'u') + ->join('address a') + ->render()); + $this->assertEquals('left join "address" as "a" on "a"."user_id" = "u"."id"', $this->q('[join]') + ->table('user', 'u') + ->join('address.user_id a') + ->render()); + $this->assertEquals('left join "address" as "a" on "a"."user_id" = "u"."id" ' . 'left join "bank" as "b" on "b"."id" = "u"."bank_id"', $this->q('[join]') + ->table('user', 'u') + ->join([ + 'a' => 'address.user_id', + 'b' => 'bank' + ]) + ->render()); + $this->assertEquals('left join "address" on "address"."user_id" = "u"."id" ' . 'left join "bank" on "bank"."id" = "u"."bank_id"', $this->q('[join]') + ->table('user', 'u') + ->join([ + 'address.user_id', + 'bank' + ]) + ->render()); + $this->assertEquals('left join "address" as "a" on "a"."user_id" = "u"."id" ' . 'left join "bank" as "b" on "b"."id" = "u"."bank_id" ' . 'left join "bank_details" on "bank_details"."id" = "bank"."details_id"', $this->q('[join]') + ->table('user', 'u') + ->join([ + 'a' => 'address.user_id', + 'b' => 'bank' + ]) + ->join('bank_details', 'bank.details_id') + ->render()); + + $this->assertEquals('left join "address" as "a" on a.name like u.pattern', $this->q('[join]') + ->table('user', 'u') + ->join('address a', new Expression('a.name like u.pattern')) + ->render()); + } + + /** + * Combined execution of where() clauses. + */ + public function testCombinedWhere() + { + $this->assertEquals('select "name" from "employee" where "a" = :a', $this->q() + ->field('name') + ->table('employee') + ->where('a', 1) + ->render()); + + $this->assertEquals('select "name" from "employee" where "employee"."a" = :a', $this->q() + ->field('name') + ->table('employee') + ->where('employee.a', 1) + ->render()); + + /* + * $this->assertEquals( + * 'select "name" from "db"."employee" where "db"."employee"."a" = :a', + * $this->q() + * ->field('name')->table('db.employee')->where('db.employee.a',1) + * ->render() + * ); + */ + + $this->assertEquals('delete from "employee" where "employee"."a" = :a', $this->q() + ->mode('delete') + ->field('name') + ->table('employee') + ->where('employee.a', 1) + ->render()); + + $user_ids = $this->q() + ->table('expired_users') + ->field('user_id'); + + $this->assertEquals('update "user" set "active"=:a where "id" in (select "user_id" from "expired_users")', $this->q() + ->table('user') + ->where('id', 'in', $user_ids) + ->set('active', 0) + ->mode('update') + ->render()); + } + + /** + * Test where() when $field is passed as array. + * Should create OR conditions. + */ + public function testOrWhere() + { + $this->assertEquals('select "name" from "employee" where ("a" = :a or "b" = :b)', $this->q() + ->field('name') + ->table('employee') + ->where([ + [ + 'a', + 1 + ], + [ + 'b', + 1 + ] + ]) + ->render()); + + $this->assertEquals('select "name" from "employee" where ("a" = :a or a=b)', $this->q() + ->field('name') + ->table('employee') + ->where([ + [ + 'a', + 1 + ], + 'a=b' + ]) + ->render()); + } + + /** + * Test OrWhere and AndWhere without where condition. + * Should ignore them. + */ + public function testEmptyOrAndWhere() + { + $this->assertEquals('', $this->q() + ->orExpr() + ->render()); + + $this->assertEquals('', $this->q() + ->andExpr() + ->render()); + } + + /** + * Test insert, update and delete templates. + */ + public function testInsertDeleteUpdate() + { + // delete template + $this->assertEquals('delete from "employee" where "name" = :a', $this->q() + ->field('name') + ->table('employee') + ->where('name', 1) + ->mode('delete') + ->render()); + + // update template + $this->assertEquals('update "employee" set "name"=:a', $this->q() + ->field('name') + ->table('employee') + ->set('name', 1) + ->mode('update') + ->render()); + + $this->assertEquals('update "employee" set "name"="name"+1', $this->q() + ->field('name') + ->table('employee') + ->set('name', new Expression('"name"+1')) + ->mode('update') + ->render()); + + // insert template + $this->assertEquals('insert into "employee" ("name") values (:a)', $this->q() + ->field('name') + ->table('employee') + ->set('name', 1) + ->mode('insert') + ->render()); + + // set multiple fields + $this->assertEquals('insert into "employee" ("time","name") values (now(),:a)', $this->q() + ->field('time') + ->field('name') + ->table('employee') + ->set('time', new Expression('now()')) + ->set('name', 'unknown') + ->mode('insert') + ->render()); + + // set as array + $this->assertEquals('insert into "employee" ("time","name") values (now(),:a)', $this->q() + ->field('time') + ->field('name') + ->table('employee') + ->set([ + 'time' => new Expression('now()'), + 'name' => 'unknown' + ]) + ->mode('insert') + ->render()); + } + + /** + * set() should return $this Query for chaining. + */ + public function testSetReturnValue() + { + $q = $this->q(); + $this->assertEquals($q, $q->set('id', 1)); + } + + /** + * Value [false] is not supported by SQL. + * + * @expectedException Exception + */ + public function testSetException1() + { + $this->q()->set('name', false); + } + + /** + * Field name can be expression. + * + * @covers ::set + */ + public function testSetException2() + { + $this->q()->set((new Expression('foo')), 1); + } + + /** + * Test nested OR and AND expressions. + */ + public function testNestedOrAnd() + { + // test 1 + $q = $this->q(); + $q->table('employee')->field('name'); + $q->where($q->orExpr() + ->where('a', 1) + ->where('b', 1)); + $this->assertEquals('select "name" from "employee" where ("a" = :a or "b" = :b)', $q->render()); + + // test 2 + $q = $this->q(); + $q->table('employee')->field('name'); + $q->where($q->orExpr() + ->where('a', 1) + ->where('b', 1) + ->where($q->andExpr() + ->where('true') + ->where('false'))); + $this->assertEquals('select "name" from "employee" where ("a" = :a or "b" = :b or (true and false))', $q->render()); + } + + /** + * Test reset(). + */ + public function testReset() + { + // reset everything + $q = $this->q() + ->table('user') + ->where('name', 'John'); + $q->reset(); + $this->assertEquals('select *', $q->render()); + + // reset particular tag + $q = $this->q() + ->table('user') + ->where('name', 'John') + ->reset('where') + ->where('surname', 'Doe'); + $this->assertEquals('select * from "user" where "surname" = :a', $q->render()); + } + + /** + * Test [option]. + */ + public function testOption() + { + // single option + $this->assertEquals('select calc_found_rows * from "test"', $this->q() + ->table('test') + ->option('calc_found_rows') + ->render()); + // multiple options + $this->assertEquals('select calc_found_rows ignore * from "test"', $this->q() + ->table('test') + ->option('calc_found_rows,ignore') + ->render()); + $this->assertEquals('select calc_found_rows ignore * from "test"', $this->q() + ->table('test') + ->option([ + 'calc_found_rows', + 'ignore' + ]) + ->render()); + // options for specific modes + $q = $this->q() + ->table('test') + ->field('name') + ->set('name', 1) + ->option('calc_found_rows', 'select') + -> + // for default select mode + option('ignore', 'insert'); // for insert mode + + $this->assertEquals('select calc_found_rows "name" from "test"', $q->mode('select') + ->render()); + $this->assertEquals('insert ignore into "test" ("name") values (:a)', $q->mode('insert') + ->render()); + $this->assertEquals('update "test" set "name"=:a', $q->mode('update') + ->render()); + } + + /** + * Test caseExpr (normal). + */ + public function testCaseExprNormal() + { + // Test normal form + $s = $this->q() + ->caseExpr() + ->when([ + 'status', + 'New' + ], 't2.expose_new') + ->when([ + 'status', + 'like', + '%Used%' + ], 't2.expose_used') + ->otherwise(null) + ->render(); + $this->assertEquals('case when "status" = :a then :b when "status" like :c then :d else :e end', $s); + + // with subqueries + $age = new Expression('year(now()) - year(birth_date)'); + $q = $this->q() + ->table('user') + ->field($age, 'calc_age'); + + $s = $this->q() + ->caseExpr() + ->when([ + 'age', + '>', + $q + ], 'Older') + ->otherwise('Younger') + ->render(); + $this->assertEquals('case when "age" > (select year(now()) - year(birth_date) "calc_age" from "user") then :a else :b end', $s); + } + + /** + * Test caseExpr (short form). + */ + public function testCaseExprShortForm() + { + $s = $this->q() + ->caseExpr('status') + ->when('New', 't2.expose_new') + ->when('Used', 't2.expose_used') + ->otherwise(null) + ->render(); + $this->assertEquals('case "status" when :a then :b when :c then :d else :e end', $s); + + // with subqueries + $age = new Expression('year(now()) - year(birth_date)'); + $q = $this->q() + ->table('user') + ->field($age, 'calc_age'); + + $s = $this->q() + ->caseExpr($q) + ->when(100, 'Very old') + ->otherwise('Younger') + ->render(); + $this->assertEquals('case (select year(now()) - year(birth_date) "calc_age" from "user") when :a then :b else :c end', $s); + } + + /** + * Incorrect use of "when" method parameters. + * + * @expected Exception Exception + */ + public function testCaseExprException1() + { + $this->q() + ->caseExpr() + ->when([ + 'status' + ], 't2.expose_new'); + } + + /** + * When using short form CASE statement, then you should not set array as when() method 1st parameter. + * + * @expected Exception Exception + */ + public function testCaseExprException2() + { + $this->q() + ->caseExpr('status') + ->when([ + 'status', + 'New' + ], 't2.expose_new'); + } + + /** + * Tests exprNow() method. + */ + public function testExprNow() + { + $this->assertEquals('update "employee" set "hired"=current_timestamp()', $this->q() + ->field('hired') + ->table('employee') + ->set('hired', $this->q() + ->exprNow()) + ->mode('update') + ->render()); + + $this->assertEquals('update "employee" set "hired"=current_timestamp(:a)', $this->q() + ->field('hired') + ->table('employee') + ->set('hired', $this->q() + ->exprNow(2)) + ->mode('update') + ->render()); + } + + /** + * Test table name with dots in it - Select. + */ + public function testTableNameDot1() + { + // render table + $this->assertEquals('"foo"."bar"', $this->callProtected($this->q() + ->table('foo.bar'), '_render_table')); + + $this->assertEquals('"foo"."bar" "a"', $this->callProtected($this->q() + ->table('foo.bar', 'a'), '_render_table')); + + // where clause + $this->assertEquals('select "name" from "db1"."employee" where "a" = :a', $this->q() + ->field('name') + ->table('db1.employee') + ->where('a', 1) + ->render()); + + $this->assertEquals('select "name" from "db1"."employee" where "db1"."employee"."a" = :a', $this->q() + ->field('name') + ->table('db1.employee') + ->where('db1.employee.a', 1) + ->render()); + } + + /** + * Test WITH. + */ + public function testWith() + { + $q1 = $this->q() + ->table('salaries') + ->field('salary'); + + $q2 = $this->q() + ->with($q1, 'q1') + ->table('q1'); + $this->assertEquals('with "q1" as (select "salary" from "salaries") select * from "q1"', $q2->render()); + + $q2 = $this->q() + ->with($q1, 'q1', null, true) + ->table('q1'); + $this->assertEquals('with recursive "q1" as (select "salary" from "salaries") select * from "q1"', $q2->render()); + + $q2 = $this->q() + ->with($q1, 'q11', [ + 'foo', + 'qwe"ry' + ]) + ->with($q1, 'q12', [ + 'bar', + 'baz' + ], true) + -> + // this one is recursive + table('q11') + ->table('q12'); + $this->assertEquals('with recursive "q11" ("foo","qwe""ry") as (select "salary" from "salaries"),"q12" ("bar","baz") as (select "salary" from "salaries") select * from "q11","q12"', $q2->render()); + + // now test some more useful reql life query + $quotes = $this->q() + ->table('quotes') + ->field('emp_id') + ->field($this->q() + ->expr('sum([])', [ + 'total_net' + ])) + ->group('emp_id'); + $invoices = $this->q() + ->table('invoices') + ->field('emp_id') + ->field($this->q() + ->expr('sum([])', [ + 'total_net' + ])) + ->group('emp_id'); + $q = $this->q() + ->with($quotes, 'q', [ + 'emp', + 'quoted' + ]) + ->with($invoices, 'i', [ + 'emp', + 'invoiced' + ]) + ->table('employees') + ->join('q.emp') + ->join('i.emp') + ->field([ + 'name', + 'salary', + 'q.quoted', + 'i.invoiced' + ]); + $this->assertEquals('with ' . '"q" ("emp","quoted") as (select "emp_id",sum(:a) from "quotes" group by "emp_id"),' . '"i" ("emp","invoiced") as (select "emp_id",sum(:b) from "invoices" group by "emp_id") ' . 'select "name","salary","q"."quoted","i"."invoiced" ' . 'from "employees" ' . 'left join "q" on "q"."emp" = "employees"."id" ' . 'left join "i" on "i"."emp" = "employees"."id"', $q->render()); + } +} diff --git a/tests/Db/RandomTest.php b/tests/Db/RandomTest.php new file mode 100644 index 00000000..82d11031 --- /dev/null +++ b/tests/Db/RandomTest.php @@ -0,0 +1,96 @@ +markTestIncomplete('This test has not been implemented yet.'); + $data = [ + 'id' => null, + 'system_id' => '3576', + 'system' => null, + 'created_dts' => 123, + 'contractor_from' => null, + 'contractor_to' => null, + 'vat_rate_id' => null, + 'currency_id' => null, + 'vat_period_id' => null, + 'journal_spec_id' => '147735', + 'job_id' => '9341', + 'nominal_id' => null, + 'root_nominal_code' => null, + 'doc_type' => null, + 'is_cn' => 'N', + 'doc_date' => null, + 'ref_no' => '940 testingqq11111', + 'po_ref' => null, + 'total_gross' => '100.00', + 'total_net' => null, + 'total_vat' => null, + 'exchange_rate' => null, + 'note' => null, + 'archive' => 'N', + 'fx_document_id' => null, + 'exchanged_total_net' => null, + 'exchanged_total_gross' => null, + 'exchanged_total_vat' => null, + 'exchanged_total_a' => null, + 'exchanged_total_b' => null + ]; + $q = $this->q(); + $q->mode('insert'); + foreach ($data as $key => $val) { + $q->set($data); + } + $this->assertEquals("insert into (`id`,`system_id`,`system`,`created_dts`,`contractor_from`,`contractor_to`,`vat_rate_id`,`currency_id`,`vat_period_id`,`journal_spec_id`,`job_id`,`nominal_id`,`root_nominal_code`,`doc_type`,`is_cn`,`doc_date`,`ref_no`,`po_ref`,`total_gross`,`total_net`,`total_vat`,`exchange_rate`,`note`,`archive`,`fx_document_id`,`exchanged_total_net`,`exchanged_total_gross`,`exchanged_total_vat`,`exchanged_total_a`,`exchanged_total_b`) values (NULL,'3576',NULL,123,NULL,NULL,NULL,NULL,NULL,'147735','9341',NULL,NULL,NULL,'N',NULL,'940 testingqq11111',NULL,'100.00',NULL,NULL,NULL,NULL,'N',NULL,NULL,NULL,NULL,NULL,NULL) [:ad, :ac, :ab, :aa, :z, :y, :x, :w, :v, :u, :t, :s, :r, :q, :p, :o, :n, :m, :l, :k, :j, :i, :h, :g, :f, :e, :d, :c, :b, :a]", $q->getDebugQuery()); + } + + /** + * confirms that group concat works for all the SQL vendors we support. + */ + public function _groupConcatTest($q, $query) + { + $q->table('people'); + $q->group('age'); + + $q->field('age'); + $q->field($q->groupConcat('name', ',')); + + $q->groupConcat('name', ','); + + $this->assertEquals($query, $q->render()); + } + + public function testGroupConcat() + { + $this->_groupConcatTest(new Query\MySQL(), 'select `age`,group_concat(`name` separator :a) from `people` group by `age`'); + + $this->_groupConcatTest(new Query\SQLite(), 'select "age",group_concat("name", :a) from "people" group by "age"'); + + $this->_groupConcatTest(new Query\PgSQL(), 'select "age",string_agg("name", :a) from "people" group by "age"'); + + $this->_groupConcatTest(new Query\Oracle(), 'select "age",listagg("name", :a) from "people" group by "age"'); + } +} diff --git a/tests/Db/db/ConnectionTest.php b/tests/Db/db/ConnectionTest.php new file mode 100644 index 00000000..5290e1cc --- /dev/null +++ b/tests/Db/db/ConnectionTest.php @@ -0,0 +1,43 @@ +expr("SELECT date('now')")->getOne(); + } + + public function testGenerator() + { + $c = new HelloWorldConnection(); + $test = 0; + foreach ($c->expr('abrakadabra') as $row) { + $test ++; + } + $this->assertEquals(10, $test); + } +} + +// @codingStandardsIgnoreStart +class HelloWorldConnection extends Connection +{ + + public function execute(Expression $e) + { + for ($x = 0; $x < 10; $x ++) { + yield $x => [ + 'greeting' => 'Hello World' + ]; + } + } + + // @codingStandardsIgnoreEnd +} diff --git a/tests/Db/db/PdoSelectTest.php b/tests/Db/db/PdoSelectTest.php new file mode 100644 index 00000000..22c64265 --- /dev/null +++ b/tests/Db/db/PdoSelectTest.php @@ -0,0 +1,20 @@ +c = Connection::connect(new \PDO($GLOBALS['DB_DSN'], $GLOBALS['DB_USER'], $GLOBALS['DB_PASSWD'])); + $this->pdo = $this->c->connection(); + + $this->pdo->query('CREATE TEMPORARY TABLE employee (id int not null, name text, surname text, retired bool, PRIMARY KEY (id))'); + } +} diff --git a/tests/Db/db/SelectTest.php b/tests/Db/db/SelectTest.php new file mode 100644 index 00000000..76c2f1f5 --- /dev/null +++ b/tests/Db/db/SelectTest.php @@ -0,0 +1,328 @@ +c = Connection::connect($GLOBALS['DB_DSN'], $GLOBALS['DB_USER'], $GLOBALS['DB_PASSWD']); + $this->pdo = $this->c->connection(); + + $this->pdo->query('CREATE TEMPORARY TABLE employee (id int not null, name text, surname text, retired bool, PRIMARY KEY (id))'); + } + + protected function getConnection() + { + return $this->createDefaultDBConnection($this->pdo, $GLOBALS['DB_DBNAME']); + } + + protected function getDataSet() + { + return $this->createFlatXMLDataSet(dirname(__FILE__) . '/SelectTest.xml'); + } + + private function q($table = null, $alias = null) + { + $q = $this->c->dsql(); + + // add table to query if specified + if ($table !== null) { + $q->table($table, $alias); + } + + return $q; + } + + private function e($template = null, $args = null) + { + return $this->c->expr($template, $args); + } + + public function testBasicQueries() + { + $this->assertEquals(4, $this->getConnection() + ->getRowCount('employee')); + + $this->assertEquals([ + 'name' => 'Oliver', + 'surname' => 'Smith' + ], $this->q('employee') + ->field('name,surname') + ->getRow()); + + $this->assertEquals([ + 'surname' => 'Taylor' + ], $this->q('employee') + ->field('surname') + ->where('retired', '1') + ->getRow()); + + $this->assertEquals(4, $this->q() + ->field(new Expression('2+2')) + ->getOne()); + + $this->assertEquals(4, $this->q('employee') + ->field(new Expression('count(*)')) + ->getOne()); + + $names = []; + foreach ($this->q('employee')->where('retired', false) as $row) { + $names[] = $row['name']; + } + $this->assertEquals([ + 'Oliver', + 'Jack', + 'Charlie' + ], $names); + + $this->assertEquals([ + [ + 'now' => 4 + ] + ], $this->q() + ->field(new Expression('2+2'), 'now') + ->get()); + + /* + * Postgresql needs to have values cast, to make the query work. + * But CAST(.. AS int) does not work in mysql. So we use two different tests.. + * (CAST(.. AS int) will work on mariaDB, whereas mysql needs it to be CAST(.. AS signed)) + */ + if ('pgsql' === $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME)) { + $this->assertEquals([ + [ + 'now' => 6 + ] + ], $this->q() + ->field(new Expression('CAST([] AS int)+CAST([] AS int)', [ + 3, + 3 + ]), 'now') + ->get()); + } else { + $this->assertEquals([ + [ + 'now' => 6 + ] + ], $this->q() + ->field(new Expression('[]+[]', [ + 3, + 3 + ]), 'now') + ->get()); + } + + $this->assertEquals(5, $this->q() + ->field(new Expression('COALESCE([],5)', [ + null + ]), 'null_test') + ->getOne()); + } + + public function testExpression() + { + /* + * Postgresql, at least versions before 10, needs to have the string cast to the + * correct datatype. + * But using CAST(.. AS CHAR) will return one single character on postgresql, but the + * entire string on mysql. + */ + if ('pgsql' === $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME)) { + $this->assertEquals('foo', $this->e('select CAST([] AS TEXT)', [ + 'foo' + ]) + ->getOne()); + } else { + $this->assertEquals('foo', $this->e('select CAST([] AS CHAR)', [ + 'foo' + ]) + ->getOne()); + } + } + + /** + * covers atk4\dsql\Expression::__toString, but on PHP 5.5 this hint doesn't work. + */ + public function testCastingToString() + { + // simple value + $this->assertEquals('Williams', (string) $this->q('employee') + ->field('surname') + ->where('name', 'Jack')); + // table as sub-query + $this->assertEquals('Williams', (string) $this->q($this->q('employee'), 'e2') + ->field('surname') + ->where('name', 'Jack')); + // field as expression + $this->assertEquals('Williams', (string) $this->q('employee') + ->field($this->e('surname')) + ->where('name', 'Jack')); + // cast to string multiple times + $q = $this->q('employee') + ->field('surname') + ->where('name', 'Jack'); + $this->assertEquals([ + 'Williams', + 'Williams' + ], [ + (string) $q, + (string) $q + ]); + // cast custom Expression to string + $this->assertEquals('7', (string) $this->e('select 3+4')); + } + + public function testOtherQueries() + { + // truncate table + $this->q('employee')->truncate(); + $this->assertEquals(0, $this->q('employee') + ->field(new Expression('count(*)')) + ->getOne()); + + // insert + $this->q('employee') + ->set([ + 'id' => 1, + 'name' => 'John', + 'surname' => 'Doe', + 'retired' => 1 + ]) + ->insert(); + $this->q('employee') + ->set([ + 'id' => 2, + 'name' => 'Jane', + 'surname' => 'Doe', + 'retired' => 0 + ]) + ->insert(); + $this->assertEquals([ + [ + 'id' => 1, + 'name' => 'John' + ], + [ + 'id' => 2, + 'name' => 'Jane' + ] + ], $this->q('employee') + ->field('id,name') + ->order('id') + ->get()); + $this->assertEquals([ + [ + 'id' => 1, + 'name' => 'John' + ], + [ + 'id' => 2, + 'name' => 'Jane' + ] + ], $this->q('employee') + ->field('id,name') + ->order('id') + ->select() + ->fetchAll()); + + // update + $this->q('employee') + ->where('name', 'John') + ->set('name', 'Johnny') + ->update(); + $this->assertEquals([ + [ + 'id' => 1, + 'name' => 'Johnny' + ], + [ + 'id' => 2, + 'name' => 'Jane' + ] + ], $this->q('employee') + ->field('id,name') + ->order('id') + ->get()); + + // replace + if ('pgsql' !== $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME)) { + $this->q('employee') + ->set([ + 'id' => 1, + 'name' => 'Peter', + 'surname' => 'Doe', + 'retired' => 1 + ]) + ->replace(); + } else { + $this->q('employee') + ->set([ + 'name' => 'Peter', + 'surname' => 'Doe', + 'retired' => 1 + ]) + ->where('id', 1) + ->update(); + } + + // In SQLite replace is just like insert, it just checks if there is + // duplicate key and if it is it deletes the row, and inserts the new + // one, otherwise it just inserts. + // So order of records after REPLACE in SQLite will be [Jane, Peter] + // not [Peter, Jane] as in MySQL, which in theory does the same thing, + // but returns [Peter, Jane] - in original order. + // That's why we add usort here. + $data = $this->q('employee') + ->field('id,name') + ->get(); + usort($data, function ($a, $b) { + return $a['id'] - $b['id']; + }); + $this->assertEquals([ + [ + 'id' => 1, + 'name' => 'Peter' + ], + [ + 'id' => 2, + 'name' => 'Jane' + ] + ], $data); + + // delete + $this->q('employee') + ->where('retired', 1) + ->delete(); + $this->assertEquals([ + [ + 'id' => 2, + 'name' => 'Jane' + ] + ], $this->q('employee') + ->field('id,name') + ->get()); + } + + /** + * + * @expectedException Exception + */ + public function testEmptyGetOne() + { + // truncate table + $this->q('employee')->truncate(); + $this->q('employee') + ->field('name') + ->getOne(); + } +} diff --git a/tests/Db/db/SelectTest.xml b/tests/Db/db/SelectTest.xml new file mode 100644 index 00000000..4f3cbc71 --- /dev/null +++ b/tests/Db/db/SelectTest.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/Db/db/TransactionTest.php b/tests/Db/db/TransactionTest.php new file mode 100644 index 00000000..e6b264f6 --- /dev/null +++ b/tests/Db/db/TransactionTest.php @@ -0,0 +1,288 @@ +c = Connection::connect($GLOBALS['DB_DSN'], $GLOBALS['DB_USER'], $GLOBALS['DB_PASSWD']); +// $this->pdo = $this->c->connection(); + +// $this->pdo->query('CREATE TEMPORARY TABLE employee (id int not null, name text, surname text, retired bool, PRIMARY KEY (id))'); +// } + +// /** +// * @return PHPUnit_Extensions_Database_DB_IDatabaseConnection +// */ +// protected function getConnection() +// { +// return $this->createDefaultDBConnection($this->pdo, $GLOBALS['DB_DBNAME']); +// } + +// /** +// * @return PHPUnit_Extensions_Database_DataSet_IDataSet +// */ +// protected function getDataSet() +// { +// return $this->createFlatXMLDataSet(dirname(__FILE__).'/SelectTest.xml'); +// } + +// private function q($table = null, $alias = null) +// { +// $q = $this->c->dsql(); + +// // add table to query if specified +// if ($table !== null) { +// $q->table($table, $alias); +// } + +// return $q; +// } + +// private function e($template = null, $args = null) +// { +// return $this->c->expr($template, $args); +// } + +// /** +// * @expectedException Exception +// */ +// public function testCommitException1() +// { +// // try to commit when not in transaction +// $this->c->commit(); +// } + +// /** +// * @expectedException Exception +// */ +// public function testCommitException2() +// { +// // try to commit when not in transaction anymore +// $this->c->beginTransaction(); +// $this->c->commit(); +// $this->c->commit(); +// } + +// /** +// * @expectedException Exception +// */ +// public function testRollbackException1() +// { +// // try to rollback when not in transaction +// $this->c->rollBack(); +// } + +// /** +// * @expectedException Exception +// */ +// public function testRollbackException2() +// { +// // try to rollback when not in transaction anymore +// $this->c->beginTransaction(); +// $this->c->rollBack(); +// $this->c->rollBack(); +// } + +// /** +// * Tests simple and nested transactions. +// */ +// public function testTransactions() +// { +// // truncate table, prepare +// $this->q('employee')->truncate(); +// $this->assertEquals( +// 0, +// $this->q('employee')->field(new Expression('count(*)'))->getOne() +// ); + +// // without transaction, ignoring exceptions +// try { +// $this->q('employee') +// ->set(['id' => 1, 'name' => 'John', 'surname' => 'Doe', 'retired' => 1]) +// ->insert(); +// $this->q('employee') +// ->set(['id' => 2, 'FOO' => 'bar', 'name' => 'Jane', 'surname' => 'Doe', 'retired' => 0]) +// ->insert(); +// } catch (\Exception $e) { +// // ignore +// } + +// $this->assertEquals( +// 1, +// $this->q('employee')->field(new Expression('count(*)'))->getOne() +// ); + +// // 1-level transaction: begin, insert, 2, rollback, 1 +// $this->c->beginTransaction(); +// $this->q('employee') +// ->set(['id' => 3, 'name' => 'John', 'surname' => 'Doe', 'retired' => 1]) +// ->insert(); +// $this->assertEquals( +// 2, +// $this->q('employee')->field(new Expression('count(*)'))->getOne() +// ); + +// $this->c->rollBack(); +// $this->assertEquals( +// 1, +// $this->q('employee')->field(new Expression('count(*)'))->getOne() +// ); + +// // atomic method, rolls back everything inside atomic() callback in case of exception +// try { +// $this->c->atomic(function () { +// $this->q('employee') +// ->set(['id' => 3, 'name' => 'John', 'surname' => 'Doe', 'retired' => 1]) +// ->insert(); +// $this->q('employee') +// ->set(['id' => 4, 'FOO' => 'bar', 'name' => 'Jane', 'surname' => 'Doe', 'retired' => 0]) +// ->insert(); +// }); +// } catch (\Exception $e) { +// // ignore +// } + +// $this->assertEquals( +// 1, +// $this->q('employee')->field(new Expression('count(*)'))->getOne() +// ); + +// // atomic method, nested atomic transaction, rolls back everything +// try { +// $this->c->atomic(function () { +// $this->q('employee') +// ->set(['id' => 3, 'name' => 'John', 'surname' => 'Doe', 'retired' => 1]) +// ->insert(); + +// // success, in, fail, out, fail +// $this->c->atomic(function () { +// $this->q('employee') +// ->set(['id' => 4, 'name' => 'John', 'surname' => 'Doe', 'retired' => 1]) +// ->insert(); +// $this->q('employee') +// ->set(['id' => 5, 'FOO' => 'bar', 'name' => 'Jane', 'surname' => 'Doe', 'retired' => 0]) +// ->insert(); +// }); + +// $this->q('employee') +// ->set(['id' => 6, 'FOO' => 'bar', 'name' => 'Jane', 'surname' => 'Doe', 'retired' => 0]) +// ->insert(); +// }); +// } catch (\Exception $e) { +// // ignore +// } + +// $this->assertEquals( +// 1, +// $this->q('employee')->field(new Expression('count(*)'))->getOne() +// ); + +// // atomic method, nested atomic transaction, rolls back everything +// try { +// $this->c->atomic(function () { +// $this->q('employee') +// ->set(['id' => 3, 'name' => 'John', 'surname' => 'Doe', 'retired' => 1]) +// ->insert(); + +// // success, in, success, out, fail +// $this->c->atomic(function () { +// $this->q('employee') +// ->set(['id' => 4, 'name' => 'John', 'surname' => 'Doe', 'retired' => 1]) +// ->insert(); +// }); + +// $this->q('employee') +// ->set(['id' => 5, 'FOO' => 'bar', 'name' => 'Jane', 'surname' => 'Doe', 'retired' => 0]) +// ->insert(); +// }); +// } catch (\Exception $e) { +// // ignore +// } + +// $this->assertEquals( +// 1, +// $this->q('employee')->field(new Expression('count(*)'))->getOne() +// ); + +// // atomic method, nested atomic transaction, rolls back everything +// try { +// $this->c->atomic(function () { +// $this->q('employee') +// ->set(['id' => 3, 'name' => 'John', 'surname' => 'Doe', 'retired' => 1]) +// ->insert(); + +// // success, in, fail, out, catch exception +// $this->c->atomic(function () { +// $this->q('employee') +// ->set(['id' => 4, 'FOO' => 'bar', 'name' => 'John', 'surname' => 'Doe', 'retired' => 1]) +// ->insert(); +// }); + +// $this->q('employee') +// ->set(['id' => 5, 'name' => 'Jane', 'surname' => 'Doe', 'retired' => 0]) +// ->insert(); +// }); +// } catch (\Exception $e) { +// // ignore +// } + +// $this->assertEquals( +// 1, +// $this->q('employee')->field(new Expression('count(*)'))->getOne() +// ); + +// // atomic method, success - commit +// try { +// $this->c->atomic(function () { +// $this->q('employee') +// ->set(['id' => 3, 'name' => 'John', 'surname' => 'Doe', 'retired' => 1]) +// ->insert(); +// }); +// } catch (\Exception $e) { +// // ignore +// } + +// $this->assertEquals( +// 2, +// $this->q('employee')->field(new Expression('count(*)'))->getOne() +// ); +// } + +// /** +// * Tests inTransaction(). +// */ +// public function testInTransaction() +// { +// // inTransaction tests +// $this->assertEquals( +// false, +// $this->c->inTransaction() +// ); + +// $this->c->beginTransaction(); +// $this->assertEquals( +// true, +// $this->c->inTransaction() +// ); + +// $this->c->rollBack(); +// $this->assertEquals( +// false, +// $this->c->inTransaction() +// ); + +// $this->c->beginTransaction(); +// $this->c->commit(); +// $this->assertEquals( +// false, +// $this->c->inTransaction() +// ); +// } +// } diff --git a/tests/ExceptionWrapper.php b/tests/ExceptionWrapper.php new file mode 100644 index 00000000..7eb76c92 --- /dev/null +++ b/tests/ExceptionWrapper.php @@ -0,0 +1,25 @@ +previous = $previous; + parent::__construct($message, $code, $previous); + } +} diff --git a/tests/PlufResultPrinter.php b/tests/PlufResultPrinter.php new file mode 100644 index 00000000..f45882a9 --- /dev/null +++ b/tests/PlufResultPrinter.php @@ -0,0 +1,28 @@ +thrownException(); + if (! $e instanceof ExceptionWrapper) { + parent::printDefectTrace($defect); + + return; + } + $this->write((string) $e); + + $p = $e->getPrevious(); + + if ($p instanceof \Pluf\Exception) { + $this->write($p->getColorfulText()); + } + } +} \ No newline at end of file diff --git a/tests/PlufTestCase.php b/tests/PlufTestCase.php new file mode 100644 index 00000000..20691c07 --- /dev/null +++ b/tests/PlufTestCase.php @@ -0,0 +1,75 @@ +getMethod($name); + $method->setAccessible(true); + + return $method->invokeArgs($obj, $args); + } + + /** + * Returns protected property value. + * + * NOTE: this method must only be used for low-level functionality, not + * for general test-scripts. + * + * @param object $obj + * @param string $name + * + * @throws \ReflectionException + * + * @return mixed + */ + public function getProtected($obj, $name) + { + $class = new \ReflectionClass($obj); + $method = $class->getProperty($name); + $method->setAccessible(true); + + return $method->getValue($obj); + } + + /** + * Fake test. + * Otherwise Travis gives warning that there are no tests in here. + */ + public function testFake() + { + $this->assertTrue(true); + } +}