Symfony2 Doctrine Migrations with Unique Indexes (Slugs)
This is something me and my colleagues encounter from time to time.
The Problem
One of the problems of adding unique indexes to existing data, like adding the sluggable Doctrine behaviour using the StofDoctrineExtensionsBundle, is that the generated migration will end up throwing an error:
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '' for key 'UNIQ_BDAFD8C8989D9B62'
Causes
Since the default values for the new MySQL column are not unique, adding the unique index is not possible - which is what the error above is telling us. So we will need to change the migration to also generate the unique values before adding the index.
Solution
In order to do so, we will have to split the generated migration into 2 different migrations, one for adding the new column, which could be a slug, and the other to add the unique index. After running the first migration, we need to execute the code that generates the unique values needed for the index. We can use the postUp
method in a Doctrine migration to execute code after the “up” migration finished. We will also need to instantiate and boot the kernel in order to gain access to the Symfony framework and build our functionality like we would do in a controller:
<?php namespace Application\Migrations; use Doctrine\DBAL\Migrations\AbstractMigration; use Doctrine\DBAL\Schema\Schema; /** * Auto-generated Migration: Please modify to your needs! */ class Version20151021133750 extends AbstractMigration { protected static $class = 'AppKernel'; protected static $kernel; /** * Creates a Kernel. * * Available options: * * * environment * * debug * * @param array $options An array of options * * @return HttpKernelInterface A HttpKernelInterface instance */ protected static function createKernel(array $options = array()) { if (null === static::$class) { static::$class = static::getKernelClass(); } return new static::$class( isset($options['environment']) ? $options['environment'] : 'test', isset($options['debug']) ? $options['debug'] : true ); } /** * Creates a Client. * * @param array $options An array of options to pass to the createKernel class * @param array $server An array of server parameters * * @return Client A Client instance */ protected static function createClient(array $options = array(), array $server = array()) { if (null !== static::$kernel) { static::$kernel->shutdown(); } static::$kernel = static::createKernel($options); static::$kernel->boot(); $client = static::$kernel->getContainer()->get('test.client'); $client->setServerParameters($server); return $client; } /** * @param Schema $schema */ public function up(Schema $schema) { // this up() migration is auto-generated, please modify it to your needs $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); $this->addSql('ALTER TABLE book ADD slug VARCHAR(128) DEFAULT ""'); //$this->addSql('CREATE UNIQUE INDEX UNIQ_CBE5A331989D9B62 ON book (slug)'); } public function postUp(Schema $schema) { $this->client = self::createClient(); $this->em = $this->client->getKernel()->getContainer()->get('doctrine')->getEntityManager(); $books = $this->em->getRepository('AppBundle:Book')->findAll(); foreach($books as $book){ // need this so we force the generation of a new slug $book->setSlug(null); $this->em->persist($book); } $this->em->flush(); } /** * @param Schema $schema */ public function down(Schema $schema) { // this down() migration is auto-generated, please modify it to your needs $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); //$this->addSql('DROP INDEX UNIQ_CBE5A331989D9B62 ON book'); $this->addSql('ALTER TABLE book DROP slug'); } }
For the second migration file we only add the code necessary to add/remove the unique indexes:
<?php namespace Application\Migrations; use Doctrine\DBAL\Migrations\AbstractMigration; use Doctrine\DBAL\Schema\Schema; /** * Auto-generated Migration: Please modify to your needs! */ class Version20151021141028 extends AbstractMigration { /** * @param Schema $schema */ public function up(Schema $schema) { // this up() migration is auto-generated, please modify it to your needs $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); $this->addSql('ALTER TABLE book CHANGE slug slug VARCHAR(128) NOT NULL'); $this->addSql('CREATE UNIQUE INDEX UNIQ_CBE5A331989D9B62 ON book (slug)'); } /** * @param Schema $schema */ public function down(Schema $schema) { // this down() migration is auto-generated, please modify it to your needs $this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); $this->addSql('DROP INDEX UNIQ_CBE5A331989D9B62 ON book'); $this->addSql('ALTER TABLE book CHANGE slug slug VARCHAR(128) DEFAULT \'\' COLLATE utf8_unicode_ci'); } }
You can generate an empty migration file using the doctrine:migratios:generate
command.
If you now run the doctrine:migrations:migrate
command everything should be fine and the database should be populated with the unique values we needed in the first place.
Conclusion
Luckily I solved this issue before an important deadline. Let me know if you found any other way around it, or a quicker solution to this issue.
How to Create a Custom Symfony2 Password Encoder
As you advance through your Symfony2 developer life, you will probably encounter the need to create a custom Symfony2 password encoder for you project. One of the most common reasons to do this, is when you migrate from an old project (different technology) and you have to keep users together with their working passwords. As you probably cannot find out the plain passwords to be able to just save them to your new database, you will need to replicate the algorithm used to encode them so they will keep working when the transition is over.
How to Create a Custom Symfony2 Password Encoder
In order to add a new, custom, password encoder to your Symfony2 project, you will need to create the encoder class, register it as a service and then specify it in the security.yml configuration file of your project.
Below you will find the necessary code to implement this:
AppBundle/Security/Core/Encoder/MyPasswordEncoder.php
<?php namespace AppBundle\Security\Core\Encoder; use Symfony\Component\Security\Core\Encoder\BasePasswordEncoder; use Symfony\Component\Security\Core\Exception\BadCredentialsException; class MyPasswordEncoder extends BasePasswordEncoder { private $ignorePasswordCase; /** * Constructor. * * @param bool $ignorePasswordCase Compare password case-insensitive */ public function __construct($ignorePasswordCase = false) { $this->ignorePasswordCase = $ignorePasswordCase; } /** * {@inheritdoc} */ public function encodePassword($raw, $salt) { if ($this->isPasswordTooLong($raw)) { throw new BadCredentialsException('Invalid password.'); } return sha1($this->mergePasswordAndSalt($raw, $salt)); } /** * {@inheritdoc} */ public function isPasswordValid($encoded, $raw, $salt) { if ($this->isPasswordTooLong($raw)) { return false; } try { $pass2 = $this->encodePassword($raw, $salt); } catch (BadCredentialsException $e) { return false; } if (!$this->ignorePasswordCase) { return $this->comparePasswords($encoded, $pass2); } return $this->comparePasswords(strtolower($encoded), strtolower($pass2)); } /** * Merges a password and a salt. * * @param string $password the password to be used * @param string $salt the salt to be used * * @return string a merged password and salt * * @throws \InvalidArgumentException */ protected function mergePasswordAndSalt($password, $salt) { if (empty($salt)) { return $password; } return $salt.$password; } }
app/config/services.yml
services: app.my_password_encoder: class: AppBundle\Security\Core\Encoder\MyPasswordEncoder
app/config/security.yml
security: encoders: FOS\UserBundle\Model\UserInterface: id: app.my_password_encoder
How to Fix Symfony2 Ajax Login Redirect
You probably noticed that sometimes an Ajax request will return the login page instead of the actual content is should return. This happens when the user has beed logged out in the background and the current page does not reflect that (it could happen if the session expired or if the user simply logged out from another browser window/tab).
How to Fix Symfony2 Ajax Login Redirect
Here's a quick way to fix this: we will create an event listener that will catch this authentication exception, check for an Ajax request and, if found, it will return a 403 http code instead of redirecting to the login page. The JavaScript code will then know to reload the page and thus redirect to login in case of 403 instead of loading and showing the received content to the user.
Here's the Symfony2 event listener:
<?php // src/AppBundle/EventListener/AjaxAuthenticationListener.php namespace AppBundle\EventListener; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Exception\AccessDeniedException; use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent; /** */ class AjaxAuthenticationListener { /** * Handles security related exceptions. * * @param GetResponseForExceptionEvent $event An GetResponseForExceptionEvent instance */ public function onCoreException(GetResponseForExceptionEvent $event) { $exception = $event->getException(); $request = $event->getRequest(); if ($request->isXmlHttpRequest()) { if ($exception instanceof AuthenticationException || $exception instanceof AccessDeniedException) { $event->setResponse(new Response('', 403)); } } } }
As always, we will have to register it as a service:
services: ajax.authentication.listener: class: AppBundle\EventListener\AjaxAuthenticationListener tags: - { name: kernel.event_listener, event: kernel.exception, method: onCoreException, priority: 1000 }
How to Fix Symfony2 Ajax Login Redirect
In the JavaScript code we add the following to make jQuery treat the Ajax errors by reloading the window in case of a 403 error. What will actually happen is that the user will end on the login page as he is no longer authenticated.
$(document).ready(function() { $(document).ajaxError(function (event, jqXHR) { if (403 === jqXHR.status) { window.location.reload(); } }); });
How To Enable Email Confirmation On Fosuserbundle Profile Edit
We all know and use FOSUserBundle in our Symfony applications, so much it became kind of a standard. It provides everything you need for user management: login, registration, email confirmation and much more control over the access of the user in your application. But we found a thing missing from this awesome package: email confirmation after the initial email address has been changed through a profile edit. In the following lines we will show you how to extend the FOSUserBundle to implement this.
How To Enable Email Confirmation On Fosuserbundle Profile Edit
This post assumes you are familiar (even advanced) with the Symfony framework and FOSUserBundle.
To get started we will need a listener to be triggered when a profile edit has happened, FOSUserBundle fires two events that we are interested in: FOSUserEvents::PROFILE_EDIT_INITIALIZE
and FOSUserEvents::PROFILE_EDIT_SUCCESS
. The first one is triggered before the actual profile data is changed so we will use that to get a hold on the original email address. When the second event is fired, we will compare the initial email address with the current one and, if they are not the same, we will start the confirmation process:
<?php // src/AppBundle/EventListener/ProfileEditListener.php namespace AppBundle\EventListener; use FOS\UserBundle\FOSUserEvents; use FOS\UserBundle\Event\FormEvent; use FOS\UserBundle\Event\GetResponseUserEvent; use FOS\UserBundle\Mailer\MailerInterface; use FOS\UserBundle\Util\TokenGeneratorInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\HttpFoundation\Session\SessionInterface; class ProfileEditListener implements EventSubscriberInterface { private $oldEmail; private $mailer; private $tokenGenerator; private $router; private $session; private $tokenStorage; public function __construct(MailerInterface $mailer, TokenGeneratorInterface $tokenGenerator, UrlGeneratorInterface $router, SessionInterface $session, TokenStorageInterface $tokenStorage) { $this->mailer = $mailer; $this->tokenGenerator = $tokenGenerator; $this->router = $router; $this->session = $session; $this->tokenStorage = $tokenStorage; } public static function getSubscribedEvents() { return array( FOSUserEvents::PROFILE_EDIT_INITIALIZE => 'onProfileEditInitialize', FOSUserEvents::PROFILE_EDIT_SUCCESS => 'onProfileEditSuccess' ); } public function onProfileEditInitialize(GetResponseUserEvent $event) { $this->oldEmail = $event->getUser()->getEmail(); } public function onProfileEditSuccess(FormEvent $event) { $user = $event->getForm()->getData(); if ($user->getEmail() !== $this->oldEmail) { // disable user $user->setEnabled(false); // send confirmation token to new email $user->setConfirmationToken($this->tokenGenerator->generateToken()); $this->mailer->sendConfirmationEmailMessage($user); // force user to log-out $this->tokenStorage->setToken(); // redirect user to check email page $this->session->set('fos_user_send_confirmation_email/email', $user->getEmail()); $url = $this->router->generate('fos_user_registration_check_email'); $event->setResponse(new RedirectResponse($url)); } } }
Now, add this to your services.yml
file and you're good to go:
app.profile_edit_listener: class: AppBundle\EventListener\ProfileEditListener arguments: [@fos_user.mailer, @fos_user.util.token_generator, @router, @session, @security.token_storage] tags: - { name: kernel.event_subscriber }
How To Enable Email Confirmation On Fosuserbundle Profile Edit
One last thing: you will probably want to change the email template that is sent to the user with the confirmation link. You can overwrite it by creating app/Resources/FOSUserBundle/views/Registration/email.txt.twig
and put what you need in there (use the original one from vendor/friendsofsymfony/user-bundle/Resources/views/Registration/email.txt.twig
to see how to get the confirmation link).
How to Build an Android Login Screen
Many applications require users to login in order to access all the available features. In this tutorial we will secure an Android activity by requiring users to enter their username and password.
We will start by creating a new blank activity with fragment, LoginActivity.
We will use the following layout for the login fragment (2 edit texts for username & password, 1 button to submit the login form and one text view to show any errors that could happen):
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin" android:paddingBottom="@dimen/activity_vertical_margin" tools:context="com.sendgrid.android.sendgrid.app.LoginActivity$PlaceholderFragment"> <EditText android:layout_width="wrap_content" android:layout_height="wrap_content" android:inputType="textEmailAddress" android:ems="10" android:id="@+id/username" android:hint="Username" android:layout_centerVertical="true" android:layout_centerHorizontal="true" /> <EditText android:layout_width="wrap_content" android:layout_height="wrap_content" android:inputType="textPassword" android:ems="10" android:id="@+id/password" android:layout_below="@+id/username" android:layout_alignLeft="@+id/editText" android:layout_alignStart="@+id/editText" android:hint="Password" /> <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Login" android:id="@+id/button" android:layout_below="@+id/password" android:layout_alignLeft="@+id/editText2" android:layout_alignStart="@+id/editText2" android:layout_alignRight="@+id/editText2" android:layout_alignEnd="@+id/editText2" android:onClick="tryLogin"/> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="" android:id="@+id/error" android:layout_above="@+id/username" android:layout_centerHorizontal="true" android:textColor="#ffff0000" /> </RelativeLayout>
Before writing the actual login code, we will create a helper class to read & save related values to Android's SharedPreferences:
public class Utility { public static Boolean isUserLoggedIn(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); return prefs.getBoolean("isUserLoggedIn", false); } public static void setUserLoggedIn(Context context, Boolean isLoggedIn) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); SharedPreferences.Editor editor = prefs.edit(); editor.putBoolean("isUserLoggedIn", isLoggedIn); editor.commit(); } public static void logout(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); SharedPreferences.Editor editor = prefs.edit(); editor.putBoolean("isUserLoggedIn", false); editor.commit(); } public static void saveUsernameAndPassword(Context context, String username, String password) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); SharedPreferences.Editor editor = prefs.edit(); editor.putString("username", username); editor.putString("password", password); editor.commit(); } }
Now let's see the actual login code:
public class LoginActivity extends ActionBarActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_login); if (savedInstanceState == null) { getSupportFragmentManager().beginTransaction() .add(R.id.container, new LoginFragment()) .commit(); } } @Override public boolean onCreateOptionsMenu(Menu menu) { return false; } public void tryLogin(View view) { LoginFragment fragment = (LoginFragment) getSupportFragmentManager().findFragmentById(R.id.container); fragment.tryLogin(view); } /** * A placeholder fragment containing a simple view. */ public static class LoginFragment extends Fragment { public LoginFragment() { } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View rootView = inflater.inflate(R.layout.fragment_login, container, false); return rootView; } public void login(Boolean result) { Utility.setUserLoggedIn(getActivity(), result); if(result) { EditText user = (EditText)getActivity().findViewById(R.id.username); EditText pass = (EditText)getActivity().findViewById(R.id.password); String username = user.getText().toString(); String password = pass.getText().toString(); Utility.saveUsernameAndPassword(getActivity(), username, password); getActivity().finish(); } else { Utility.saveUsernameAndPassword(getActivity(), null, null); TextView error = (TextView)getActivity().findViewById(R.id.error); error.setText("Login failed! Please try again."); } } public void tryLogin(View view) { EditText user = (EditText)getActivity().findViewById(R.id.username); EditText pass = (EditText)getActivity().findViewById(R.id.password); String username = username.getText().toString(); String password = password.getText().toString(); if(!username.isEmpty() && !password.isEmpty()) { TextView error = (TextView)getActivity().findViewById(R.id.error); error.setText(""); CheckLoginTask loginTask = new CheckLoginTask(); loginTask.execute(username, password); } } // we will use an AsyncTask to connect to an API service to check the username and the password // the doInBackground method will return true if the login succeeds public class CheckLoginTask extends AsyncTask<String, Void, Boolean> { @Override protected Boolean doInBackground(String... params) { HttpsURLConnection urlConnection = null; BufferedReader reader = null; String responseJsonStr = null; try { // Construct the URL for the get User query final String GET_PROFILE_BASE_URL ="https://api.domain.com/user?"; Uri builtUri = Uri.parse(GET_PROFILE_BASE_URL).buildUpon().build(); URL url = new URL(builtUri.toString()); // Create the request to server and open the connection urlConnection = (HttpsURLConnection) url.openConnection(); // Create the SSL connection SSLContext sc; sc = SSLContext.getInstance("TLS"); sc.init(null, null, new SecureRandom()); urlConnection.setSSLSocketFactory(sc.getSocketFactory()); // Add API credentials String user = params[0]; String password = params[1]; String userpass = user + ":" + password; // Create the Authentication Token String basicAuth = "Basic " + Base64.encodeToString(userpass.getBytes(), Base64.DEFAULT); // Add the required Headers. urlConnection.addRequestProperty("Authorization", basicAuth); urlConnection.addRequestProperty("Content-Type", "application/json"); urlConnection.setRequestProperty("accept", "application/json"); // Method urlConnection.setRequestMethod("GET"); // Connect urlConnection.connect(); int status = urlConnection.getResponseCode(); String reason = urlConnection.getResponseMessage(); Log.v("LOGIN", status + reason); // Read the input stream into a String InputStream inputStream = urlConnection.getInputStream(); StringBuffer buffer = new StringBuffer(); if (inputStream == null) { // Nothing to do here return null; } reader = new BufferedReader(new InputStreamReader(inputStream)); String line; while ((line = reader.readLine()) != null) { // Since it's JSON, adding a newline isn't necessary (it won't affect parsing) // But it does make debugging a *lot* easier if you print out the completed // buffer for debugging. buffer.append(line + "\n"); } if (buffer.length() == 0) { // Stream was empty. No point in parsing. return null; } responseJsonStr = buffer.toString(); getNameDataFromJson(responseJsonStr); } catch (IOException | NoSuchAlgorithmException | JSONException | KeyManagementException e) { Log.e("LOGIN", "Error", e); return false; } finally { if (urlConnection != null) { urlConnection.disconnect(); } if (reader != null) { try { reader.close(); } catch (final IOException e) { } } } // if we reach here it means we successfully logged in return true; } @Override protected void onPostExecute(Boolean result) { super.onPostExecute(result); login(result); } } } }
In the end, all that's left is to start this activity whenever we need the user to login:
public class MainActivity extends Activity { // ... @Override protected void onResume() { super.onResume(); if(!Utility.isUserLoggedIn(this)){ startActivity(new Intent(this, LoginActivity.class)); } } // ... }
How to Store Latitude and Longitude in MySQL
As we know, a latitude value can be between -90 and +90 degrees, whereas a longitude value can be between -180 and +180 degrees. To get accuracy to within a meter, only six decimal places are needed, which is sufficient for most cases. But you can go even further by using eight places which will give you more than centimeter-level accuracy.Read more
How Teams Use Git for Big Projects
Every time a new member joins our team we have to guide him through our Git workflow. So I decided to write everything here in order to simply send them the link (and help others interested in learning how a real development team uses Git).
Linux Commands Every Web Developer Should Know
Here's a list of Linux commands I found very useful throughout the years. You might want to bookmark it as you'll surely need it again someday.
How to Add Share Buttons to Symfony2 Generated Web Pages
Some projects require you to add social share buttons for their pages. I'm gonna show you a simple way to do this using the Socialitejs JavaScript library.
First you're gonna have to download the Socialitejs library, save it to your bundle Resources/public/js
folder and publish the assets.
If you have a general layout that is inherited by all your specific page templates (like it's usually the case), just add the share code in it like so:Read more
Symfony2: Gedmo Softdeletable Doctrine Entities With Unique Index Columns
There is a problem when using the SoftDeletable Doctrine extension for entities that have some unique index columns. The most obvious example is User entities. An User has at least one column that needs to be unique (username and/or email). When soft-deleting an User the actual record will stay in the db table. Now if you try to create a new one with the same username/email, the validation will pass (doctrine will not see the existing soft-deleted one) but the database layer will not be able to save the new record:
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry ... for key …